暂不支持移动端访问

BlogRoom 3D 场景、相机与交互状态

1582 字 8 分钟
AI 摘要

拆解程序化几何、正交相机、GSAP 动画、Zustand 状态和房间物件命令。

BlogRoom 的 3D 场景由程序化几何组合,而不是导入一个整体室内模型。这样家具的尺寸、线条和交互状态都能在代码中独立调整。场景层只负责空间对象,业务面板和数据内容仍由 React DOM 管理。

房间场景组合#

src/scene/room-scene.tsx 是对象装配入口。它依次挂载 CameraRigSceneTickerRoomShell、桌面、显示器、电脑、键盘、门、窗户、书架、留声机、床和其他家具,再挂载字形层、后处理和线稿揭示动画。对象组件放在 src/scene/objects/,每个组件负责几何、材质、局部动画和 pointer 事件。

TSX
{/* 所有家具共享这一局部坐标原点,单个组件只维护自身偏移。 */}
<group position={[0, -0.85, 0]}>
<RoomShell />
<Desk />
<Monitor />
<Laptop />
<Keyboard />
<Bookshelf />
<Door />
...
</group>

所有家具共享场景的坐标基准和主题颜色。新增物件必须先确定它属于哪个空间区域、是否需要碰撞体、是否需要 pointer cursor,以及是否在移动端可见。不要在单个家具内部创建全局弹窗或直接修改另一个家具的状态。

相机区域和焦点#

camera-rig.tsx 定义三种区域:loungeoverviewworkspace,每个区域包含相机位置、lookAt 目标和 zoom;还定义 bookshelf、door、keyboard、laptop、portrait 等焦点。Canvas 使用正交相机,因此 zoom 改变的是可视范围,不是透视距离。

TypeScript
export const cameraZones = {
// lounge 是默认阅读区,镜头同时覆盖书架。
lounge: { position: [20, 14.5, 23], target: [0.8, 1.8, 1.8], zoom: 70 },
// overview 保留完整房间,用于总览和状态恢复。
overview: { position: [20, 14.5, 22], target: [0, 2.85, 0.18], zoom: 58 },
workspace: { position: [18.5, 13.2, 21], target: [-0.8, 2.7, -5.8], zoom: 77 },
};

桌面端切换焦点时,CameraRig 使用 GSAP 对 position、lookAt target 和 zoom 做 1.18 秒的贝塞尔插值;动画开始点读取相机当前值,所以连续点击不同物件时不会跳回区域初始点。用户开启 reduced motion 时直接设置终点,不创建动画。移动端禁用自动焦点,只显示 MapControls 的平移,避免小屏幕上相机把用户带离上下文。

Zustand 状态模型#

room-store.ts 将状态分成四组:相机 (cameraZonecameraFocus)、面板 (panel 和 selected ID)、物件状态 (objectState) 以及主题/声音/门出口等全局状态。状态更新通过动作函数完成,例如 openBook() 会清除其他选中 ID、关闭门提示并把 panel 设为 book

TypeScript
openBook: (selectedBookId) =>
set((state) => ({
// 书籍弹窗会取代临时门提示和其他内容选中状态。
isDoorExitPromptOpen: false,
objectState: { ...state.objectState, doorOpen: false },
panel: 'book',
selectedBookId,
selectedFeedId: null,
selectedLinkId: null,
})),

场景对象只订阅自己需要的字段。台灯读取 objectState.deskLampOn,门读取 doorOpen,显示器读取 monitorOn;面板只订阅 panel 和对应 selected ID。这样切换书籍不会让所有家具重新渲染。

命令分发#

room-commands.ts 把物件 ID 转换为业务动作。activateRoomObject('bookshelf') 随机选择一个已同步的书籍 ID 后调用 openBook()laptop 打开 GitHub 面板;mouse 切换显示器状态;wall-switch 切换主题并播放开关声音。家具组件只传入自己的 ID,不需要知道业务面板组件的实现。

新增物件时要同时处理对象组件、room-scene.tsx 的挂载、roomObjects 定义和 activateRoomObject() 的分支。如果它打开面板,还要增加 PanelId 类型、app.tsx 的 dialog 挂载和对应 feature。漏掉其中一层,常见结果是物件可点击但没有动作,或面板打开后相机没有焦点。

动画状态和渲染循环#

房间 Canvas 使用 frameloop="demand",需要连续变化的对象由 SceneTicker 或组件中的 useFrame 请求 invalidate。台灯、窗帘、门、风扇等动画读取 store 状态,再用 GSAP 或 useFrame 更新局部 transform;动画结束后应停止无意义的 invalidate,避免静态页面保持高帧率。

主题和声音状态写入 localStorage。ThemeSync 监听 store 的 theme 并同步 Canvas 背景色、CSS class 和 DOM 变量;声音开关只影响 playRoomSoundplayObjectSound,不应该阻止数据请求或相机交互。

无障碍和降级#

Canvas 本身标记为 aria-hidden,所以所有核心入口应有 DOM 面板、键盘可达按钮或静态 fallback。RoomFallback 使用 /assets/fallback/room.svg,不能依赖 Three.js 才能访问个人资料、书籍、链接和退出入口。新增家具前要决定它在不支持 WebGL 时是否需要额外的 DOM 导航项。

修改检查#

Terminal windowpowershell
# 检查场景组件、store、命令和 feature 的类型关系。
pnpm typecheck
# 检查 React/TSX、CSS 和无障碍相关 lint 规则。
pnpm lint
# 验证同步数据、TypeScript 编译和生产场景 bundle。
pnpm build

修改相机时检查桌面、移动和 reduced motion;修改命令时检查状态互斥关系;修改对象几何时检查线稿后处理和点击区域;修改面板时检查 Query 加载、错误、关闭后相机恢复以及键盘焦点。3D 画面能显示不代表状态链路完整,至少要测试从物件点击到状态、相机、面板和关闭恢复的整条路径。

程序化几何的组织方式#

家具组件通常由基础几何、线框边缘和少量局部材质组成,尺寸通过常量或 props 控制。公共的线稿材质、阴影和 hover 反馈应放在可复用工具中,不能每个家具重新创建一套颜色和后处理参数。这样调整主题或线宽时只改一层,避免场景出现不同黑色、不同发光强度和不同点击反馈。

场景坐标以房间中心和 group position={[0, -0.85, 0]} 为基准,家具内部再使用局部坐标。新增物件时先在 overview 区域确认全局位置,再为 lounge/workspace 焦点定义 target;不要通过改变整个房间 group 来修正一个家具,否则所有 camera target 和碰撞体都会偏移。

面板关闭和相机恢复#

打开面板时 store 记录 focusReturnZoneapp.tsx 根据 panel 映射 camera focus;面板关闭后调用 restoreCameraFocus() 返回原区域。门出口提示属于特殊状态,它优先级高于普通 panel,会将相机聚焦到 door 并关闭其他选中项。新增一个高优先级提示时,应明确它与 panel、cameraFocus 和 objectState 的优先级,不能只新增一个 boolean。

[ 公告 ]

如果你喜欢,那么欢迎来到我的世界!

了解更多
[ 音乐 ]
封面

音乐

找不到相关结果。
[ 目录 ]
[ 全部文章 ]