feat: add lightweight decorative scene runtime

This commit is contained in:
2026-09-05 17:12:40 +08:00
parent ab29846194
commit 1bfb78479a
2 changed files with 280 additions and 0 deletions

View File

@@ -0,0 +1,146 @@
import * as THREE from 'three';
import type { SceneTheme } from './ThreeCanvas';
export interface ScenePalette {
accent: number;
accentSoft: number;
edge: number;
foliage: number;
ground: number;
mountain: number;
mountainDark: number;
mountainLight: number;
snow: number;
trunk: number;
water: number;
}
export const palettes: Record<SceneTheme, ScenePalette> = {
light: {
accent: 0x3d8afa,
accentSoft: 0x82b5ff,
edge: 0x1557a6,
foliage: 0x1557a6,
ground: 0xf8faff,
mountain: 0x1557a6,
mountainDark: 0x091c3f,
mountainLight: 0x82b5ff,
snow: 0xe6f0fe,
trunk: 0x0b2f5f,
water: 0x3d8afa,
},
dark: {
accent: 0x82b5ff,
accentSoft: 0x3d8afa,
edge: 0x426b9d,
foliage: 0x3d8afa,
ground: 0x07132f,
mountain: 0x0b2f5f,
mountainDark: 0x010d27,
mountainLight: 0x103f7f,
snow: 0x38526e,
trunk: 0x091c3f,
water: 0x1557a6,
},
};
export function fitOrthographicCamera(
camera: THREE.OrthographicCamera,
width: number,
height: number,
viewHeight: number,
) {
const aspect = width / Math.max(height, 1);
camera.left = (-viewHeight * aspect) / 2;
camera.right = (viewHeight * aspect) / 2;
camera.top = viewHeight / 2;
camera.bottom = -viewHeight / 2;
camera.updateProjectionMatrix();
}
export function createMesa(
width: number,
height: number,
depth: number,
palette: ScenePalette,
) {
const halfWidth = width / 2;
const halfDepth = depth / 2;
const inset = Math.min(width, depth) * 0.19;
const positions = [
-halfWidth, 0, -halfDepth, halfWidth, 0, -halfDepth, halfWidth, 0, halfDepth, -halfWidth, 0, halfDepth,
-halfWidth + inset, height, -halfDepth + inset, halfWidth - inset, height, -halfDepth + inset,
halfWidth - inset, height, halfDepth - inset, -halfWidth + inset, height, halfDepth - inset,
];
const indices = [
4, 5, 6, 4, 6, 7,
0, 1, 5, 0, 5, 4,
1, 2, 6, 1, 6, 5,
2, 3, 7, 2, 7, 6,
3, 0, 4, 3, 4, 7,
];
const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
geometry.setIndex(indices);
geometry.computeVertexNormals();
const mesa = new THREE.Mesh(
geometry,
new THREE.MeshStandardMaterial({
color: palette.mountain,
flatShading: true,
roughness: 0.9,
}),
);
mesa.castShadow = true;
mesa.receiveShadow = true;
return mesa;
}
export function createTree(palette: ScenePalette, scale = 1, pill = false) {
const tree = new THREE.Group();
const trunk = new THREE.Mesh(
new THREE.CylinderGeometry(0.08 * scale, 0.1 * scale, 0.45 * scale, 8),
new THREE.MeshStandardMaterial({ color: palette.trunk, roughness: 1 }),
);
trunk.position.y = 0.22 * scale;
tree.add(trunk);
const crown = new THREE.Mesh(
pill
? new THREE.CapsuleGeometry(0.2 * scale, 0.5 * scale, 4, 8)
: new THREE.ConeGeometry(0.38 * scale, 1.25 * scale, 10),
new THREE.MeshStandardMaterial({
color: pill ? palette.accentSoft : palette.foliage,
flatShading: true,
roughness: 0.9,
}),
);
crown.position.y = pill ? 0.9 * scale : 0.95 * scale;
crown.castShadow = true;
tree.add(crown);
return tree;
}
export function addSoftLights(scene: THREE.Scene, theme: SceneTheme, shadowArea: number) {
scene.add(
new THREE.HemisphereLight(
theme === 'dark' ? 0x3d8afa : 0xffffff,
theme === 'dark' ? 0x010d27 : 0xc3cfe0,
theme === 'dark' ? 1.7 : 2.8,
),
);
const key = new THREE.DirectionalLight(0xffffff, theme === 'dark' ? 1.7 : 2.8);
key.position.set(-7, 12, 8);
key.castShadow = true;
key.shadow.mapSize.set(1024, 1024);
key.shadow.camera.left = -shadowArea;
key.shadow.camera.right = shadowArea;
key.shadow.camera.top = shadowArea;
key.shadow.camera.bottom = -shadowArea;
key.shadow.camera.near = 1;
key.shadow.camera.far = 40;
key.shadow.bias = -0.0004;
scene.add(key);
}