431 lines
15 KiB
JavaScript
431 lines
15 KiB
JavaScript
|
|
/**
|
|||
|
|
* 艾动智能 · 36 仓充电柜 参数化 3D 模型(Three.js)
|
|||
|
|
*
|
|||
|
|
* 设计约束(AGENTS.md「操作规则」):
|
|||
|
|
* - 明亮 / 浅色基调,⛔ 不做暗色科技风;
|
|||
|
|
* - 柜子规格按真实产品:6 列 × 6 行 = 36 个独立仓体,每仓带状态指示灯。
|
|||
|
|
*
|
|||
|
|
* 工程约束:
|
|||
|
|
* - canvas 透明背景(alpha),柜子"浮"在页面浅色渐变上 ⇒ 不存在图片硬边界;
|
|||
|
|
* - 纯参数化建模,不依赖任何外部模型文件(我们没有、也生成不出高精度模型资产);
|
|||
|
|
* - 性能:DPR 上限 2、页面隐藏/滚出视口时暂停渲染;
|
|||
|
|
* - 降级:WebGL 不可用或脚本异常 ⇒ 页面回落到静态主视觉图(见 index.html)。
|
|||
|
|
*/
|
|||
|
|
|
|||
|
|
import * as THREE from 'three';
|
|||
|
|
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
|||
|
|
|
|||
|
|
/* ============================ 规格常量 ============================ */
|
|||
|
|
const COLS = 6;
|
|||
|
|
const ROWS = 6;
|
|||
|
|
const CELL_W = 0.62; // 单仓横向步进
|
|||
|
|
const CELL_H = 0.72; // 单仓纵向步进
|
|||
|
|
const DOOR_W = 0.54;
|
|||
|
|
const DOOR_H = 0.64;
|
|||
|
|
const BODY_H = ROWS * CELL_H; // 4.32
|
|||
|
|
const BASE_H = 0.20;
|
|||
|
|
const CAP_H = 0.24;
|
|||
|
|
|
|||
|
|
/* 深度(Z 轴)。
|
|||
|
|
⚠️ 原先柜体只有 0.62 深,而宽 3.72 ⇒ 宽:深 ≈ 6:1,薄得像一块板,
|
|||
|
|
从侧面看尤其难看。真实充电柜深得多,这里加到 1.18 ⇒ 宽:深 ≈ 3.2:1。 */
|
|||
|
|
const BODY_D = 1.18;
|
|||
|
|
const CAP_D = 1.24; // 顶盖略外挑
|
|||
|
|
const PLINTH_D = 1.22; // 底座略外挑
|
|||
|
|
const DOOR_Z = BODY_D / 2 + 0.07; // 仓门铰链平面(柜体前面之外一点)
|
|||
|
|
const CAVITY_D = 0.85; // 仓内深度
|
|||
|
|
|
|||
|
|
const BRAND = 0x0366e6; // 取自 logo 的品牌蓝
|
|||
|
|
const LIGHT_CYAN = 0x17e4ff; // 状态灯
|
|||
|
|
|
|||
|
|
/** 上面 3 排用深色玻璃仓门、下面 3 排用浅色仓门 —— 与主视觉图的层次一致 */
|
|||
|
|
const DARK_ROWS = 3;
|
|||
|
|
|
|||
|
|
/* ============================ 渲染器 ============================ */
|
|||
|
|
const canvas = document.getElementById('cabinet-canvas');
|
|||
|
|
/* 移动端降质:手机 GPU 弱,36 仓 + 阴影 + 环境反射很容易发烫掉帧。
|
|||
|
|
判据用「窄屏 或 粗指针(触摸) 或 低 DPR」三者之一。 */
|
|||
|
|
const IS_MOBILE = (window.innerWidth <= 860)
|
|||
|
|
|| (window.matchMedia && window.matchMedia('(pointer: coarse)').matches)
|
|||
|
|
|| (window.devicePixelRatio || 1) < 2;
|
|||
|
|
|
|||
|
|
const renderer = new THREE.WebGLRenderer({
|
|||
|
|
canvas,
|
|||
|
|
alpha: true, // 透明背景,让柜子浮在页面上
|
|||
|
|
antialias: !IS_MOBILE, // 移动端关 MSAA,靠 DPR 与后处理兜不住就算了
|
|||
|
|
powerPreference: 'high-performance',
|
|||
|
|
});
|
|||
|
|
renderer.setClearColor(0x000000, 0);
|
|||
|
|
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, IS_MOBILE ? 1.5 : 2));
|
|||
|
|
renderer.shadowMap.enabled = !IS_MOBILE; // 移动端用假接触阴影(地面那张),不开实时阴影
|
|||
|
|
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
|||
|
|
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
|||
|
|
renderer.toneMappingExposure = 1.05;
|
|||
|
|
|
|||
|
|
const scene = new THREE.Scene();
|
|||
|
|
|
|||
|
|
/* 相机:略低于人眼、微微仰视,产品感更强。
|
|||
|
|
⚠️ 距离是算出来的,不是凭感觉:柜体总高约 4.76 单位(底座0.20+主体4.32+顶盖0.24),
|
|||
|
|
摄像机看向 target(0, 2.36, 0)。38° 垂直视角下
|
|||
|
|
可见高度 = 2·d·tan19° = 0.689·d。
|
|||
|
|
- 原 d≈8.4 ⇒ 柜子占 82%,顶天顶地;
|
|||
|
|
- 现取 CAM_DIST=12.2 ⇒ 实际 d≈11.4 ⇒ 柜子占约 61%,上下各留约 20% 呼吸空间。 */
|
|||
|
|
const CAM_DIST = 13.0;
|
|||
|
|
const camera = new THREE.PerspectiveCamera(38, 1, 0.1, 100);
|
|||
|
|
camera.position.set(
|
|||
|
|
CAM_DIST * 0.52, // x
|
|||
|
|
CAM_DIST * 0.35, // y
|
|||
|
|
CAM_DIST * 0.76, // z
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
/* ============================ 环境反射 ============================ */
|
|||
|
|
/** 程序化生成"影棚渐变"环境贴图 —— 让深色玻璃仓门有细腻反射,无需外部 HDR 文件。 */
|
|||
|
|
function buildEnvironment() {
|
|||
|
|
const pmrem = new THREE.PMREMGenerator(renderer);
|
|||
|
|
const envScene = new THREE.Scene();
|
|||
|
|
|
|||
|
|
// 一个内部着色的渐变球:上白、中浅灰、下稍暗,模拟影棚柔光箱
|
|||
|
|
const geo = new THREE.SphereGeometry(10, 32, 16);
|
|||
|
|
const mat = new THREE.ShaderMaterial({
|
|||
|
|
side: THREE.BackSide,
|
|||
|
|
uniforms: {
|
|||
|
|
top: { value: new THREE.Color(0xffffff) },
|
|||
|
|
mid: { value: new THREE.Color(0xdfe4ec) },
|
|||
|
|
bot: { value: new THREE.Color(0x9aa3b2) },
|
|||
|
|
},
|
|||
|
|
vertexShader: `
|
|||
|
|
varying float vY;
|
|||
|
|
void main() {
|
|||
|
|
vY = normalize(position).y;
|
|||
|
|
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
|||
|
|
}`,
|
|||
|
|
fragmentShader: `
|
|||
|
|
uniform vec3 top; uniform vec3 mid; uniform vec3 bot;
|
|||
|
|
varying float vY;
|
|||
|
|
void main() {
|
|||
|
|
float t = clamp(vY * 0.5 + 0.5, 0.0, 1.0);
|
|||
|
|
vec3 c = t < 0.5 ? mix(bot, mid, t * 2.0) : mix(mid, top, (t - 0.5) * 2.0);
|
|||
|
|
gl_FragColor = vec4(c, 1.0);
|
|||
|
|
}`,
|
|||
|
|
});
|
|||
|
|
envScene.add(new THREE.Mesh(geo, mat));
|
|||
|
|
const env = pmrem.fromScene(envScene, 0.04).texture;
|
|||
|
|
pmrem.dispose();
|
|||
|
|
geo.dispose();
|
|||
|
|
mat.dispose();
|
|||
|
|
return env;
|
|||
|
|
}
|
|||
|
|
scene.environment = buildEnvironment();
|
|||
|
|
|
|||
|
|
/* ============================ 灯光 ============================ */
|
|||
|
|
function buildLights() {
|
|||
|
|
scene.add(new THREE.HemisphereLight(0xffffff, 0xc8d0dc, 0.55));
|
|||
|
|
|
|||
|
|
// 主光:右上前侧,冷白,投影(移动端为省电只留光照、不投影)
|
|||
|
|
const key = new THREE.DirectionalLight(0xffffff, 2.1);
|
|||
|
|
key.position.set(5, 8, 6);
|
|||
|
|
if (!IS_MOBILE) {
|
|||
|
|
key.castShadow = true;
|
|||
|
|
key.shadow.mapSize.set(2048, 2048);
|
|||
|
|
key.shadow.camera.near = 1;
|
|||
|
|
key.shadow.camera.far = 30;
|
|||
|
|
key.shadow.camera.left = -6;
|
|||
|
|
key.shadow.camera.right = 6;
|
|||
|
|
key.shadow.camera.top = 8;
|
|||
|
|
key.shadow.camera.bottom = -2;
|
|||
|
|
key.shadow.bias = -0.0008;
|
|||
|
|
key.shadow.normalBias = 0.02;
|
|||
|
|
}
|
|||
|
|
scene.add(key);
|
|||
|
|
|
|||
|
|
// 补光:左侧,柔
|
|||
|
|
const fill = new THREE.DirectionalLight(0xdfe8ff, 0.7);
|
|||
|
|
fill.position.set(-6, 4, 4);
|
|||
|
|
scene.add(fill);
|
|||
|
|
|
|||
|
|
// 轮廓光:品牌蓝,从左后打,勾出柜体边缘
|
|||
|
|
const rim = new THREE.DirectionalLight(BRAND, 1.5);
|
|||
|
|
rim.position.set(-5, 5, -6);
|
|||
|
|
scene.add(rim);
|
|||
|
|
|
|||
|
|
// 顶部灯带的自发光点光,让蓝光落在柜顶
|
|||
|
|
const stripGlow = new THREE.PointLight(BRAND, 6, 6, 2);
|
|||
|
|
stripGlow.position.set(0, BODY_H + CAP_H + 0.12, 1.2);
|
|||
|
|
scene.add(stripGlow);
|
|||
|
|
}
|
|||
|
|
buildLights();
|
|||
|
|
|
|||
|
|
/* ============================ 柜体模型 ============================ */
|
|||
|
|
const cabinet = new THREE.Group();
|
|||
|
|
const doors = []; // { pivot, mesh, row, col, open, target, light }
|
|||
|
|
const doorMaterialCache = new Map();
|
|||
|
|
|
|||
|
|
/** 仓门材质(按行分深浅,缓存复用) */
|
|||
|
|
function doorMaterial(dark) {
|
|||
|
|
const key = dark ? 'dark' : 'light';
|
|||
|
|
if (doorMaterialCache.has(key)) return doorMaterialCache.get(key);
|
|||
|
|
const m = new THREE.MeshStandardMaterial(
|
|||
|
|
dark
|
|||
|
|
? { color: 0x1b2432, roughness: 0.18, metalness: 0.35 } // 深色玻璃
|
|||
|
|
: { color: 0xd9dde4, roughness: 0.42, metalness: 0.12 }, // 浅色门板
|
|||
|
|
);
|
|||
|
|
doorMaterialCache.set(key, m);
|
|||
|
|
return m;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function buildBody() {
|
|||
|
|
const w = COLS * CELL_W;
|
|||
|
|
// 主体
|
|||
|
|
const body = new THREE.Mesh(
|
|||
|
|
new THREE.BoxGeometry(w, BODY_H, BODY_D),
|
|||
|
|
new THREE.MeshStandardMaterial({ color: 0xe9ebef, roughness: 0.55, metalness: 0.08 }),
|
|||
|
|
);
|
|||
|
|
body.position.y = BASE_H + BODY_H / 2;
|
|||
|
|
body.castShadow = true;
|
|||
|
|
body.receiveShadow = true;
|
|||
|
|
cabinet.add(body);
|
|||
|
|
|
|||
|
|
// 顶盖(略宽略深,深色,中间嵌品牌蓝灯带)
|
|||
|
|
const cap = new THREE.Mesh(
|
|||
|
|
new THREE.BoxGeometry(w + 0.08, CAP_H, CAP_D),
|
|||
|
|
new THREE.MeshStandardMaterial({ color: 0x2b3038, roughness: 0.4, metalness: 0.3 }),
|
|||
|
|
);
|
|||
|
|
cap.position.y = BASE_H + BODY_H + CAP_H / 2;
|
|||
|
|
cap.castShadow = true;
|
|||
|
|
cabinet.add(cap);
|
|||
|
|
|
|||
|
|
const strip = new THREE.Mesh(
|
|||
|
|
new THREE.BoxGeometry(w - 0.5, 0.045, 0.05),
|
|||
|
|
new THREE.MeshStandardMaterial({
|
|||
|
|
color: 0x0a2a5e, emissive: BRAND, emissiveIntensity: 2.4, roughness: 0.3,
|
|||
|
|
}),
|
|||
|
|
);
|
|||
|
|
strip.position.set(0, BASE_H + BODY_H + CAP_H - 0.07, CAP_D / 2 + 0.012);
|
|||
|
|
cabinet.add(strip);
|
|||
|
|
|
|||
|
|
// 底座 + 支脚
|
|||
|
|
const plinth = new THREE.Mesh(
|
|||
|
|
new THREE.BoxGeometry(w + 0.04, BASE_H, PLINTH_D),
|
|||
|
|
new THREE.MeshStandardMaterial({ color: 0x3a4048, roughness: 0.5, metalness: 0.25 }),
|
|||
|
|
);
|
|||
|
|
plinth.position.y = BASE_H / 2;
|
|||
|
|
plinth.castShadow = true;
|
|||
|
|
plinth.receiveShadow = true;
|
|||
|
|
cabinet.add(plinth);
|
|||
|
|
|
|||
|
|
for (const sx of [-1, 1]) {
|
|||
|
|
for (const sz of [-1, 1]) {
|
|||
|
|
const foot = new THREE.Mesh(
|
|||
|
|
new THREE.CylinderGeometry(0.055, 0.05, 0.06, 16),
|
|||
|
|
new THREE.MeshStandardMaterial({ color: 0x22262c, roughness: 0.6 }),
|
|||
|
|
);
|
|||
|
|
foot.position.set(sx * (w / 2 - 0.14), 0.03, sz * (PLINTH_D / 2 - 0.14));
|
|||
|
|
cabinet.add(foot);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 单个仓体:门框 + 门 + 把手 + 状态灯;门挂在 pivot 上以便绕铰链开合 */
|
|||
|
|
function buildCompartment(row, col) {
|
|||
|
|
const dark = row < ROWS / 2;
|
|||
|
|
const x = (col - (COLS - 1) / 2) * CELL_W;
|
|||
|
|
const y = BASE_H + (row + 0.5) * CELL_H;
|
|||
|
|
|
|||
|
|
// 门框(深色,凸出在柜体前面)
|
|||
|
|
const frame = new THREE.Mesh(
|
|||
|
|
new THREE.BoxGeometry(CELL_W - 0.02, CELL_H - 0.02, 0.06),
|
|||
|
|
new THREE.MeshStandardMaterial({ color: 0x353b45, roughness: 0.45, metalness: 0.3 }),
|
|||
|
|
);
|
|||
|
|
frame.position.set(x, y, BODY_D / 2 + 0.03);
|
|||
|
|
cabinet.add(frame);
|
|||
|
|
|
|||
|
|
// 铰链在左侧:pivot 放在门左缘
|
|||
|
|
const pivot = new THREE.Group();
|
|||
|
|
pivot.position.set(x - DOOR_W / 2, y, DOOR_Z);
|
|||
|
|
cabinet.add(pivot);
|
|||
|
|
|
|||
|
|
const door = new THREE.Mesh(
|
|||
|
|
new THREE.BoxGeometry(DOOR_W, DOOR_H, 0.05),
|
|||
|
|
doorMaterial(dark),
|
|||
|
|
);
|
|||
|
|
door.position.set(DOOR_W / 2, 0, 0.025);
|
|||
|
|
door.castShadow = true;
|
|||
|
|
pivot.add(door);
|
|||
|
|
|
|||
|
|
// 把手
|
|||
|
|
const handle = new THREE.Mesh(
|
|||
|
|
new THREE.BoxGeometry(0.035, 0.16, 0.035),
|
|||
|
|
new THREE.MeshStandardMaterial({ color: 0xb9c0cb, roughness: 0.25, metalness: 0.85 }),
|
|||
|
|
);
|
|||
|
|
handle.position.set(DOOR_W - 0.07, 0, 0.055);
|
|||
|
|
pivot.add(handle);
|
|||
|
|
|
|||
|
|
// 状态灯(门框右上角,自发光)
|
|||
|
|
const light = new THREE.Mesh(
|
|||
|
|
new THREE.BoxGeometry(0.1, 0.022, 0.02),
|
|||
|
|
new THREE.MeshStandardMaterial({
|
|||
|
|
color: 0x08243f, emissive: LIGHT_CYAN, emissiveIntensity: 2.2, roughness: 0.4,
|
|||
|
|
}),
|
|||
|
|
);
|
|||
|
|
light.position.set(x + CELL_W / 2 - 0.1, y + CELL_H / 2 - 0.07, BODY_D / 2 + 0.065);
|
|||
|
|
cabinet.add(light);
|
|||
|
|
|
|||
|
|
// 仓内(开门后可见的暗腔 + 弱光)
|
|||
|
|
const cavity = new THREE.Mesh(
|
|||
|
|
new THREE.BoxGeometry(CELL_W - 0.08, CELL_H - 0.08, CAVITY_D),
|
|||
|
|
new THREE.MeshStandardMaterial({ color: 0x111720, roughness: 0.9 }),
|
|||
|
|
);
|
|||
|
|
cavity.position.set(x, y, BODY_D / 2 - CAVITY_D / 2 + 0.02);
|
|||
|
|
cabinet.add(cavity);
|
|||
|
|
|
|||
|
|
doors.push({
|
|||
|
|
pivot, door, light, row, col,
|
|||
|
|
open: false, target: 0, hover: false,
|
|||
|
|
baseEmissive: 2.2, phase: (row * COLS + col) * 0.37,
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function buildCabinet() {
|
|||
|
|
buildBody();
|
|||
|
|
for (let r = 0; r < ROWS; r++) {
|
|||
|
|
for (let c = 0; c < COLS; c++) buildCompartment(r, c);
|
|||
|
|
}
|
|||
|
|
cabinet.position.y = 0;
|
|||
|
|
scene.add(cabinet);
|
|||
|
|
}
|
|||
|
|
buildCabinet();
|
|||
|
|
|
|||
|
|
/* ============================ 地面 + 软阴影 ============================ */
|
|||
|
|
/** 用一张径向渐变贴图做假接触阴影,比实时阴影便宜且边缘更柔。 */
|
|||
|
|
function buildFloor() {
|
|||
|
|
const size = 256;
|
|||
|
|
const cv = document.createElement('canvas');
|
|||
|
|
cv.width = cv.height = size;
|
|||
|
|
const ctx = cv.getContext('2d');
|
|||
|
|
const g = ctx.createRadialGradient(size / 2, size / 2, 0, size / 2, size / 2, size / 2);
|
|||
|
|
g.addColorStop(0, 'rgba(20,30,45,0.34)');
|
|||
|
|
g.addColorStop(0.55, 'rgba(20,30,45,0.12)');
|
|||
|
|
g.addColorStop(1, 'rgba(20,30,45,0)');
|
|||
|
|
ctx.fillStyle = g;
|
|||
|
|
ctx.fillRect(0, 0, size, size);
|
|||
|
|
const tex = new THREE.CanvasTexture(cv);
|
|||
|
|
|
|||
|
|
const shadow = new THREE.Mesh(
|
|||
|
|
new THREE.PlaneGeometry(7.5, 4.5),
|
|||
|
|
new THREE.MeshBasicMaterial({ map: tex, transparent: true, depthWrite: false }),
|
|||
|
|
);
|
|||
|
|
shadow.rotation.x = -Math.PI / 2;
|
|||
|
|
shadow.position.y = 0.005;
|
|||
|
|
shadow.renderOrder = -1;
|
|||
|
|
scene.add(shadow);
|
|||
|
|
}
|
|||
|
|
buildFloor();
|
|||
|
|
|
|||
|
|
/* ============================ 交互 ============================ */
|
|||
|
|
const controls = new OrbitControls(camera, renderer.domElement);
|
|||
|
|
controls.target.set(0, BASE_H + BODY_H / 2, 0);
|
|||
|
|
controls.enableDamping = true;
|
|||
|
|
controls.dampingFactor = 0.06;
|
|||
|
|
controls.enablePan = false;
|
|||
|
|
// 距离上下限跟着取景走:默认约 12.1,允许 9.5–17
|
|||
|
|
controls.minDistance = 9.5;
|
|||
|
|
controls.maxDistance = 17;
|
|||
|
|
controls.minPolarAngle = 0.35;
|
|||
|
|
controls.maxPolarAngle = Math.PI / 2 - 0.03; // 不钻到地面以下
|
|||
|
|
controls.autoRotate = true;
|
|||
|
|
controls.autoRotateSpeed = 0.55;
|
|||
|
|
// 用户一交互就停自动旋转,避免"拖着还转"
|
|||
|
|
controls.addEventListener('start', () => { controls.autoRotate = false; });
|
|||
|
|
// 触屏:单指旋转、双指缩放;页面滚动交给舞台外的区域(canvas 已设 touch-action:none)
|
|||
|
|
controls.touches = { ONE: THREE.TOUCH.ROTATE, TWO: THREE.TOUCH.DOLLY_ROTATE };
|
|||
|
|
|
|||
|
|
const raycaster = new THREE.Raycaster();
|
|||
|
|
const pointer = new THREE.Vector2(-10, -10);
|
|||
|
|
let hovered = null;
|
|||
|
|
|
|||
|
|
renderer.domElement.addEventListener('pointermove', (e) => {
|
|||
|
|
const r = renderer.domElement.getBoundingClientRect();
|
|||
|
|
pointer.x = ((e.clientX - r.left) / r.width) * 2 - 1;
|
|||
|
|
pointer.y = -((e.clientY - r.top) / r.height) * 2 + 1;
|
|||
|
|
});
|
|||
|
|
renderer.domElement.addEventListener('pointerleave', () => {
|
|||
|
|
pointer.set(-10, -10);
|
|||
|
|
});
|
|||
|
|
renderer.domElement.addEventListener('click', () => {
|
|||
|
|
if (!hovered) return;
|
|||
|
|
const d = doors.find((x) => x.door === hovered);
|
|||
|
|
if (!d) return;
|
|||
|
|
d.open = !d.open;
|
|||
|
|
d.target = d.open ? -Math.PI * 0.62 : 0;
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
/* ============================ 尺寸自适应 ============================ */
|
|||
|
|
function resize() {
|
|||
|
|
const stage = renderer.domElement.parentElement;
|
|||
|
|
const w = Math.max(1, stage.clientWidth);
|
|||
|
|
const h = Math.max(1, stage.clientHeight);
|
|||
|
|
renderer.setSize(w, h, false);
|
|||
|
|
camera.aspect = w / h;
|
|||
|
|
camera.updateProjectionMatrix();
|
|||
|
|
}
|
|||
|
|
window.addEventListener('resize', resize);
|
|||
|
|
resize();
|
|||
|
|
|
|||
|
|
/* ============================ 主循环 ============================ */
|
|||
|
|
const clock = new THREE.Clock();
|
|||
|
|
let visible = true;
|
|||
|
|
|
|||
|
|
// 页面隐藏 / 舞台滚出视口时暂停,省电省 CPU
|
|||
|
|
document.addEventListener('visibilitychange', () => {
|
|||
|
|
visible = !document.hidden;
|
|||
|
|
if (visible) clock.getDelta();
|
|||
|
|
});
|
|||
|
|
if ('IntersectionObserver' in window) {
|
|||
|
|
new IntersectionObserver((entries) => {
|
|||
|
|
visible = entries[0].isIntersecting && !document.hidden;
|
|||
|
|
if (visible) clock.getDelta();
|
|||
|
|
}, { threshold: 0.01 }).observe(renderer.domElement);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function tick() {
|
|||
|
|
requestAnimationFrame(tick);
|
|||
|
|
if (!visible) return;
|
|||
|
|
const dt = Math.min(clock.getDelta(), 0.05);
|
|||
|
|
const t = clock.elapsedTime;
|
|||
|
|
|
|||
|
|
// 仓门开合(缓动)
|
|||
|
|
for (const d of doors) {
|
|||
|
|
const cur = d.pivot.rotation.y;
|
|||
|
|
const next = cur + (d.target - cur) * Math.min(1, dt * 7);
|
|||
|
|
d.pivot.rotation.y = next;
|
|||
|
|
// 悬停高亮
|
|||
|
|
const wantEmissive = d.door === hovered ? 0.55 : 0;
|
|||
|
|
d.light.material.emissiveIntensity +=
|
|||
|
|
(d.baseEmissive + wantEmissive - d.light.material.emissiveIntensity) * Math.min(1, dt * 8);
|
|||
|
|
// 状态灯轻微呼吸(有在充的仓才亮;这里取一部分做"使用中"的观感)
|
|||
|
|
if ((d.row + d.col) % 3 === 0) {
|
|||
|
|
const pulse = 0.75 + 0.25 * Math.sin(t * 2.2 + d.phase);
|
|||
|
|
d.light.material.emissiveIntensity *= pulse;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
controls.update();
|
|||
|
|
renderer.render(scene, camera);
|
|||
|
|
}
|
|||
|
|
tick();
|
|||
|
|
|
|||
|
|
/* ============================ 悬停射线检测 ============================ */
|
|||
|
|
setInterval(() => {
|
|||
|
|
if (!visible) return;
|
|||
|
|
raycaster.setFromCamera(pointer, camera);
|
|||
|
|
const hits = raycaster.intersectObjects(doors.map((d) => d.door), false);
|
|||
|
|
const first = hits.length ? hits[0].object : null;
|
|||
|
|
if (first !== hovered) {
|
|||
|
|
hovered = first;
|
|||
|
|
renderer.domElement.style.cursor = hovered ? 'pointer' : 'grab';
|
|||
|
|
}
|
|||
|
|
}, 60);
|
|||
|
|
|
|||
|
|
// 供页面判断 3D 是否就绪(失败则回落静态图)
|
|||
|
|
window.__cabinet3d = { ok: true, doors: doors.length };
|