Improve 3D renderer: shadow camera, caching, error handling, labels
Renderer improvements: - Configure shadow camera frustum to cover full house (was default -5..5) - Add geometry and material caching to reduce GPU allocations - Add proper disposal of Three.js objects on floor switch (fix memory leak) - Add error handling for fetch failures with custom event dispatch - Add room labels as sprites floating in each room - Support wall-mounted furniture Y positioning via position.y - Use cached highlight materials instead of mutating shared materials
This commit is contained in:
@@ -100,6 +100,9 @@
|
||||
await renderer.loadDesign('../designs/sample-house-design.json');
|
||||
buildFloorButtons();
|
||||
buildRoomList();
|
||||
}).catch(err => {
|
||||
document.getElementById('house-name').textContent = 'Error loading data';
|
||||
document.getElementById('info').textContent = err.message;
|
||||
});
|
||||
|
||||
function buildFloorButtons() {
|
||||
|
||||
228
src/renderer.js
228
src/renderer.js
@@ -28,6 +28,8 @@ export class HouseRenderer {
|
||||
this.roomMeshes = new Map();
|
||||
this.roomLabels = new Map();
|
||||
this.furnitureMeshes = new Map();
|
||||
this._materialCache = new Map();
|
||||
this._geometryCache = new Map();
|
||||
|
||||
this.scene = new THREE.Scene();
|
||||
this.scene.background = new THREE.Color(0xf0f0f0);
|
||||
@@ -74,6 +76,12 @@ export class HouseRenderer {
|
||||
dir.castShadow = true;
|
||||
dir.shadow.mapSize.width = 2048;
|
||||
dir.shadow.mapSize.height = 2048;
|
||||
dir.shadow.camera.left = -15;
|
||||
dir.shadow.camera.right = 15;
|
||||
dir.shadow.camera.top = 15;
|
||||
dir.shadow.camera.bottom = -15;
|
||||
dir.shadow.camera.near = 0.5;
|
||||
dir.shadow.camera.far = 40;
|
||||
this.scene.add(dir);
|
||||
}
|
||||
|
||||
@@ -84,42 +92,56 @@ export class HouseRenderer {
|
||||
}
|
||||
|
||||
async loadHouse(url) {
|
||||
const res = await fetch(url);
|
||||
this.houseData = await res.json();
|
||||
this.showFloor(0);
|
||||
return this.houseData;
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`Failed to load house: ${res.status} ${res.statusText}`);
|
||||
this.houseData = await res.json();
|
||||
this.showFloor(0);
|
||||
return this.houseData;
|
||||
} catch (err) {
|
||||
this._emitError('loadHouse', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async loadCatalog(url) {
|
||||
const res = await fetch(url);
|
||||
this.catalogData = await res.json();
|
||||
this._catalogIndex = new Map();
|
||||
for (const item of this.catalogData.items) {
|
||||
this._catalogIndex.set(item.id, item);
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`Failed to load catalog: ${res.status} ${res.statusText}`);
|
||||
this.catalogData = await res.json();
|
||||
this._catalogIndex = new Map();
|
||||
for (const item of this.catalogData.items) {
|
||||
this._catalogIndex.set(item.id, item);
|
||||
}
|
||||
return this.catalogData;
|
||||
} catch (err) {
|
||||
this._emitError('loadCatalog', err);
|
||||
throw err;
|
||||
}
|
||||
return this.catalogData;
|
||||
}
|
||||
|
||||
async loadDesign(url) {
|
||||
const res = await fetch(url);
|
||||
this.designData = await res.json();
|
||||
this._placeFurnitureForFloor();
|
||||
return this.designData;
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`Failed to load design: ${res.status} ${res.statusText}`);
|
||||
this.designData = await res.json();
|
||||
this._placeFurnitureForFloor();
|
||||
return this.designData;
|
||||
} catch (err) {
|
||||
this._emitError('loadDesign', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
_emitError(source, error) {
|
||||
this.container.dispatchEvent(new CustomEvent('loaderror', {
|
||||
detail: { source, error: error.message }
|
||||
}));
|
||||
}
|
||||
|
||||
showFloor(index) {
|
||||
this.currentFloor = index;
|
||||
// Clear existing room meshes
|
||||
for (const group of this.roomMeshes.values()) {
|
||||
this.scene.remove(group);
|
||||
}
|
||||
this.roomMeshes.clear();
|
||||
this.roomLabels.clear();
|
||||
// Clear existing furniture
|
||||
for (const group of this.furnitureMeshes.values()) {
|
||||
this.scene.remove(group);
|
||||
}
|
||||
this.furnitureMeshes.clear();
|
||||
this._clearFloor();
|
||||
|
||||
const floor = this.houseData.floors[index];
|
||||
if (!floor) return;
|
||||
@@ -131,6 +153,38 @@ export class HouseRenderer {
|
||||
this._placeFurnitureForFloor();
|
||||
}
|
||||
|
||||
_clearFloor() {
|
||||
for (const group of this.roomMeshes.values()) {
|
||||
this.scene.remove(group);
|
||||
this._disposeGroup(group);
|
||||
}
|
||||
this.roomMeshes.clear();
|
||||
this.roomLabels.clear();
|
||||
for (const group of this.furnitureMeshes.values()) {
|
||||
this.scene.remove(group);
|
||||
this._disposeGroup(group);
|
||||
}
|
||||
this.furnitureMeshes.clear();
|
||||
// Dispose all cached GPU resources — they'll be recreated as needed
|
||||
for (const geo of this._geometryCache.values()) geo.dispose();
|
||||
this._geometryCache.clear();
|
||||
for (const mat of this._materialCache.values()) {
|
||||
if (mat.map) mat.map.dispose();
|
||||
mat.dispose();
|
||||
}
|
||||
this._materialCache.clear();
|
||||
}
|
||||
|
||||
_disposeGroup(group) {
|
||||
group.traverse(child => {
|
||||
if (child.geometry) child.geometry.dispose();
|
||||
if (child.material) {
|
||||
if (child.material.map) child.material.map.dispose();
|
||||
child.material.dispose();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_renderRoom(room, ceilingHeight) {
|
||||
const group = new THREE.Group();
|
||||
group.userData = { roomId: room.id, roomName: room.name };
|
||||
@@ -160,16 +214,67 @@ export class HouseRenderer {
|
||||
this._addWall(group, wd, wallData, ceilingHeight);
|
||||
}
|
||||
|
||||
// Room label
|
||||
const label = this._createRoomLabel(room.name, width, length, ceilingHeight);
|
||||
group.add(label);
|
||||
this.roomLabels.set(room.id, label);
|
||||
|
||||
this.scene.add(group);
|
||||
this.roomMeshes.set(room.id, group);
|
||||
}
|
||||
|
||||
_createRoomLabel(name, width, length, ceilingHeight) {
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
const fontSize = 48;
|
||||
ctx.font = `bold ${fontSize}px sans-serif`;
|
||||
const metrics = ctx.measureText(name);
|
||||
const padding = 16;
|
||||
canvas.width = metrics.width + padding * 2;
|
||||
canvas.height = fontSize + padding * 2;
|
||||
|
||||
ctx.fillStyle = 'rgba(0, 0, 0, 0.5)';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
ctx.font = `bold ${fontSize}px sans-serif`;
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(name, canvas.width / 2, canvas.height / 2);
|
||||
|
||||
const texture = new THREE.CanvasTexture(canvas);
|
||||
const mat = new THREE.SpriteMaterial({ map: texture, transparent: true });
|
||||
const sprite = new THREE.Sprite(mat);
|
||||
|
||||
const scale = 0.008;
|
||||
sprite.scale.set(canvas.width * scale, canvas.height * scale, 1);
|
||||
sprite.position.set(width / 2, ceilingHeight * 0.4, length / 2);
|
||||
|
||||
return sprite;
|
||||
}
|
||||
|
||||
_getCachedMaterial(key, createFn) {
|
||||
let mat = this._materialCache.get(key);
|
||||
if (!mat) {
|
||||
mat = createFn();
|
||||
this._materialCache.set(key, mat);
|
||||
}
|
||||
return mat;
|
||||
}
|
||||
|
||||
_getCachedGeometry(key, createFn) {
|
||||
let geo = this._geometryCache.get(key);
|
||||
if (!geo) {
|
||||
geo = createFn();
|
||||
this._geometryCache.set(key, geo);
|
||||
}
|
||||
return geo;
|
||||
}
|
||||
|
||||
_addFloor(group, width, length, flooring) {
|
||||
const geo = new THREE.PlaneGeometry(width, length);
|
||||
const mat = new THREE.MeshStandardMaterial({
|
||||
color: COLORS.floor[flooring] || COLORS.floor.hardwood,
|
||||
roughness: 0.8
|
||||
});
|
||||
const geo = this._getCachedGeometry(`plane:${width}:${length}`, () => new THREE.PlaneGeometry(width, length));
|
||||
const color = COLORS.floor[flooring] || COLORS.floor.hardwood;
|
||||
const mat = this._getCachedMaterial(`floor:${color}`, () => new THREE.MeshStandardMaterial({ color, roughness: 0.8 }));
|
||||
const mesh = new THREE.Mesh(geo, mat);
|
||||
mesh.rotation.x = -Math.PI / 2;
|
||||
mesh.position.set(width / 2, 0, length / 2);
|
||||
@@ -178,13 +283,13 @@ export class HouseRenderer {
|
||||
}
|
||||
|
||||
_addCeiling(group, width, length, height) {
|
||||
const geo = new THREE.PlaneGeometry(width, length);
|
||||
const mat = new THREE.MeshStandardMaterial({
|
||||
const geo = this._getCachedGeometry(`plane:${width}:${length}`, () => new THREE.PlaneGeometry(width, length));
|
||||
const mat = this._getCachedMaterial('ceiling', () => new THREE.MeshStandardMaterial({
|
||||
color: COLORS.ceiling,
|
||||
transparent: true,
|
||||
opacity: 0.3,
|
||||
side: THREE.DoubleSide
|
||||
});
|
||||
}));
|
||||
const mesh = new THREE.Mesh(geo, mat);
|
||||
mesh.rotation.x = Math.PI / 2;
|
||||
mesh.position.set(width / 2, height, length / 2);
|
||||
@@ -202,13 +307,15 @@ export class HouseRenderer {
|
||||
const wallWidth = wallDef.wallWidth;
|
||||
const segments = this._computeWallSegments(openings, wallWidth, ceilingHeight);
|
||||
|
||||
const matKey = `wall:${wallColor}`;
|
||||
const mat = this._getCachedMaterial(matKey, () => new THREE.MeshStandardMaterial({ color: wallColor, roughness: 0.9 }));
|
||||
|
||||
for (const seg of segments) {
|
||||
const geo = new THREE.BoxGeometry(seg.w, seg.h, thickness);
|
||||
const mat = new THREE.MeshStandardMaterial({ color: wallColor, roughness: 0.9 });
|
||||
const geo = this._getCachedGeometry(`box:${seg.w}:${seg.h}:${thickness}`, () => new THREE.BoxGeometry(seg.w, seg.h, thickness));
|
||||
const mesh = new THREE.Mesh(geo, mat);
|
||||
mesh.castShadow = true;
|
||||
mesh.receiveShadow = true;
|
||||
mesh.userData = { isWall: true, roomId: group.userData.roomId };
|
||||
mesh.userData = { isWall: true, roomId: group.userData.roomId, materialKey: matKey };
|
||||
|
||||
if (wallDef.horizontal) {
|
||||
mesh.position.set(seg.cx, seg.cy, wallDef.pos);
|
||||
@@ -274,8 +381,9 @@ export class HouseRenderer {
|
||||
}
|
||||
|
||||
_addDoorMesh(group, wallDef, door, thickness) {
|
||||
const geo = new THREE.BoxGeometry(door.width, door.height, thickness * 0.5);
|
||||
const mat = new THREE.MeshStandardMaterial({ color: COLORS.door, roughness: 0.6 });
|
||||
const t = thickness * 0.5;
|
||||
const geo = this._getCachedGeometry(`box:${door.width}:${door.height}:${t}`, () => new THREE.BoxGeometry(door.width, door.height, t));
|
||||
const mat = this._getCachedMaterial('door', () => new THREE.MeshStandardMaterial({ color: COLORS.door, roughness: 0.6 }));
|
||||
const mesh = new THREE.Mesh(geo, mat);
|
||||
|
||||
const cx = door.position + door.width / 2;
|
||||
@@ -292,13 +400,14 @@ export class HouseRenderer {
|
||||
|
||||
_addWindowMesh(group, wallDef, win, thickness) {
|
||||
// Glass pane
|
||||
const geo = new THREE.BoxGeometry(win.width, win.height, thickness * 0.3);
|
||||
const mat = new THREE.MeshStandardMaterial({
|
||||
const t = thickness * 0.3;
|
||||
const geo = this._getCachedGeometry(`box:${win.width}:${win.height}:${t}`, () => new THREE.BoxGeometry(win.width, win.height, t));
|
||||
const mat = this._getCachedMaterial('window', () => new THREE.MeshStandardMaterial({
|
||||
color: COLORS.window,
|
||||
transparent: true,
|
||||
opacity: 0.4,
|
||||
roughness: 0.1
|
||||
});
|
||||
}));
|
||||
const mesh = new THREE.Mesh(geo, mat);
|
||||
|
||||
const cx = win.position + win.width / 2;
|
||||
@@ -313,8 +422,8 @@ export class HouseRenderer {
|
||||
group.add(mesh);
|
||||
|
||||
// Window frame
|
||||
const frameGeo = new THREE.EdgesGeometry(geo);
|
||||
const frameMat = new THREE.LineBasicMaterial({ color: COLORS.windowFrame });
|
||||
const frameGeo = this._getCachedGeometry(`edges:${win.width}:${win.height}:${t}`, () => new THREE.EdgesGeometry(geo));
|
||||
const frameMat = this._getCachedMaterial('windowFrame', () => new THREE.LineBasicMaterial({ color: COLORS.windowFrame }));
|
||||
const frame = new THREE.LineSegments(frameGeo, frameMat);
|
||||
frame.position.copy(mesh.position);
|
||||
frame.rotation.copy(mesh.rotation);
|
||||
@@ -322,21 +431,28 @@ export class HouseRenderer {
|
||||
}
|
||||
|
||||
highlightRoom(roomId) {
|
||||
// Reset all rooms
|
||||
// Reset all rooms - restore original shared materials
|
||||
for (const [id, group] of this.roomMeshes) {
|
||||
group.traverse(child => {
|
||||
if (child.isMesh && child.material && child.userData.isWall) {
|
||||
child.material.emissive?.setHex(0x000000);
|
||||
if (child.isMesh && child.userData.isWall && child.userData._origMaterial) {
|
||||
child.material = child.userData._origMaterial;
|
||||
delete child.userData._origMaterial;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Highlight target
|
||||
// Highlight target - swap to cached highlight variant
|
||||
const target = this.roomMeshes.get(roomId);
|
||||
if (target) {
|
||||
target.traverse(child => {
|
||||
if (child.isMesh && child.userData.isWall) {
|
||||
child.material.emissive.setHex(0x111133);
|
||||
const baseKey = child.userData.materialKey;
|
||||
child.userData._origMaterial = child.material;
|
||||
child.material = this._getCachedMaterial(`${baseKey}:highlight`, () => {
|
||||
const m = child.material.clone();
|
||||
m.emissive.setHex(0x111133);
|
||||
return m;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -426,7 +542,10 @@ export class HouseRenderer {
|
||||
// Position in room-local coords, then offset by room position
|
||||
const rx = room.position.x + placement.position.x;
|
||||
const rz = room.position.y + placement.position.z;
|
||||
mesh.position.set(rx, 0, rz);
|
||||
// Wall-mounted items (mirrors, wall cabinets) use position.y for mount
|
||||
// height offset; floor-standing items are placed at y=0
|
||||
const ry = placement.wallMounted ? (placement.position.y ?? 0) : 0;
|
||||
mesh.position.set(rx, ry, rz);
|
||||
mesh.rotation.y = -(placement.rotation * Math.PI) / 180;
|
||||
|
||||
const key = `${roomDesign.roomId}-${placement.instanceId || placement.catalogId}-${i}`;
|
||||
@@ -434,7 +553,8 @@ export class HouseRenderer {
|
||||
isFurniture: true,
|
||||
catalogId: placement.catalogId,
|
||||
roomId: roomDesign.roomId,
|
||||
itemName: catalogItem.name
|
||||
itemName: catalogItem.name,
|
||||
wallMounted: !!placement.wallMounted
|
||||
};
|
||||
|
||||
this.scene.add(mesh);
|
||||
@@ -451,17 +571,17 @@ export class HouseRenderer {
|
||||
for (const part of meshDef.parts) {
|
||||
let geo;
|
||||
if (part.geometry === 'box') {
|
||||
geo = new THREE.BoxGeometry(part.size[0], part.size[1], part.size[2]);
|
||||
geo = this._getCachedGeometry(`box:${part.size[0]}:${part.size[1]}:${part.size[2]}`, () => new THREE.BoxGeometry(part.size[0], part.size[1], part.size[2]));
|
||||
} else if (part.geometry === 'cylinder') {
|
||||
geo = new THREE.CylinderGeometry(part.radius, part.radius, part.height, 16);
|
||||
geo = this._getCachedGeometry(`cyl:${part.radius}:${part.height}`, () => new THREE.CylinderGeometry(part.radius, part.radius, part.height, 16));
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
|
||||
const mat = new THREE.MeshStandardMaterial({
|
||||
const mat = this._getCachedMaterial(`furniture:${part.color}`, () => new THREE.MeshStandardMaterial({
|
||||
color: new THREE.Color(part.color),
|
||||
roughness: 0.7
|
||||
});
|
||||
}));
|
||||
|
||||
const mesh = new THREE.Mesh(geo, mat);
|
||||
mesh.position.set(part.position[0], part.position[1], part.position[2]);
|
||||
|
||||
Reference in New Issue
Block a user