feat: add lightweight decorative scene runtime
This commit is contained in:
134
src/components/scenes/ThreeCanvas.tsx
Normal file
134
src/components/scenes/ThreeCanvas.tsx
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import * as THREE from 'three';
|
||||||
|
|
||||||
|
export type SceneTheme = 'light' | 'dark';
|
||||||
|
|
||||||
|
export interface SceneContext {
|
||||||
|
renderer: THREE.WebGLRenderer;
|
||||||
|
pointer: THREE.Vector2;
|
||||||
|
theme: SceneTheme;
|
||||||
|
reduceMotion: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SceneController {
|
||||||
|
scene: THREE.Scene;
|
||||||
|
camera: THREE.Camera;
|
||||||
|
resize: (width: number, height: number) => void;
|
||||||
|
update?: (elapsed: number, delta: number) => void;
|
||||||
|
dispose?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SceneFactory = (context: SceneContext) => SceneController;
|
||||||
|
|
||||||
|
interface ThreeCanvasProps {
|
||||||
|
className: string;
|
||||||
|
createScene: SceneFactory;
|
||||||
|
theme: SceneTheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
function disposeObject(object: THREE.Object3D) {
|
||||||
|
const disposable = object as THREE.Object3D & {
|
||||||
|
geometry?: THREE.BufferGeometry;
|
||||||
|
material?: THREE.Material | THREE.Material[];
|
||||||
|
};
|
||||||
|
|
||||||
|
disposable.geometry?.dispose();
|
||||||
|
const materials = Array.isArray(disposable.material)
|
||||||
|
? disposable.material
|
||||||
|
: disposable.material
|
||||||
|
? [disposable.material]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
materials.forEach((material) => {
|
||||||
|
Object.values(material).forEach((value) => {
|
||||||
|
if (value instanceof THREE.Texture) value.dispose();
|
||||||
|
});
|
||||||
|
material.dispose();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ThreeCanvas({ className, createScene, theme }: ThreeCanvasProps) {
|
||||||
|
const hostRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const host = hostRef.current;
|
||||||
|
if (!host) return;
|
||||||
|
|
||||||
|
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||||
|
const renderer = new THREE.WebGLRenderer({ alpha: true, antialias: true });
|
||||||
|
const pointer = new THREE.Vector2();
|
||||||
|
const controller = createScene({ renderer, pointer, theme, reduceMotion });
|
||||||
|
const clock = new THREE.Clock();
|
||||||
|
let frame = 0;
|
||||||
|
let visible = true;
|
||||||
|
|
||||||
|
renderer.setClearColor(0x000000, 0);
|
||||||
|
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
||||||
|
renderer.shadowMap.enabled = true;
|
||||||
|
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
||||||
|
renderer.domElement.setAttribute('aria-hidden', 'true');
|
||||||
|
host.appendChild(renderer.domElement);
|
||||||
|
|
||||||
|
const render = () => renderer.render(controller.scene, controller.camera);
|
||||||
|
const resize = () => {
|
||||||
|
const bounds = host.getBoundingClientRect();
|
||||||
|
if (!bounds.width || !bounds.height) return;
|
||||||
|
|
||||||
|
renderer.setPixelRatio(Math.min(window.devicePixelRatio, bounds.width < 640 ? 1.25 : 1.65));
|
||||||
|
renderer.setSize(bounds.width, bounds.height, false);
|
||||||
|
controller.resize(bounds.width, bounds.height);
|
||||||
|
render();
|
||||||
|
};
|
||||||
|
|
||||||
|
const animate = () => {
|
||||||
|
if (!visible || reduceMotion) return;
|
||||||
|
const delta = Math.min(clock.getDelta(), 0.05);
|
||||||
|
controller.update?.(clock.elapsedTime, delta);
|
||||||
|
render();
|
||||||
|
frame = window.requestAnimationFrame(animate);
|
||||||
|
};
|
||||||
|
|
||||||
|
const resizeObserver = new ResizeObserver(resize);
|
||||||
|
resizeObserver.observe(host);
|
||||||
|
|
||||||
|
const visibilityObserver = new IntersectionObserver(
|
||||||
|
([entry]) => {
|
||||||
|
visible = entry.isIntersecting;
|
||||||
|
window.cancelAnimationFrame(frame);
|
||||||
|
if (visible && !reduceMotion) {
|
||||||
|
clock.getDelta();
|
||||||
|
frame = window.requestAnimationFrame(animate);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ rootMargin: '160px' },
|
||||||
|
);
|
||||||
|
visibilityObserver.observe(host);
|
||||||
|
|
||||||
|
const onPointerMove = (event: PointerEvent) => {
|
||||||
|
if (reduceMotion || !window.matchMedia('(hover: hover) and (pointer: fine)').matches) return;
|
||||||
|
pointer.set(
|
||||||
|
(event.clientX / window.innerWidth) * 2 - 1,
|
||||||
|
-((event.clientY / window.innerHeight) * 2 - 1),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
window.addEventListener('pointermove', onPointerMove, { passive: true });
|
||||||
|
|
||||||
|
resize();
|
||||||
|
if (!reduceMotion) frame = window.requestAnimationFrame(animate);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.cancelAnimationFrame(frame);
|
||||||
|
window.removeEventListener('pointermove', onPointerMove);
|
||||||
|
resizeObserver.disconnect();
|
||||||
|
visibilityObserver.disconnect();
|
||||||
|
controller.dispose?.();
|
||||||
|
controller.scene.traverse(disposeObject);
|
||||||
|
renderer.dispose();
|
||||||
|
renderer.domElement.remove();
|
||||||
|
};
|
||||||
|
}, [createScene, theme]);
|
||||||
|
|
||||||
|
return <div ref={hostRef} className={className} aria-hidden="true" />;
|
||||||
|
}
|
||||||
146
src/components/scenes/scenePrimitives.ts
Normal file
146
src/components/scenes/scenePrimitives.ts
Normal 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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user