Day 111 · 资产集成与边界 — 加载态、资源管理与降级
前九天你把资产做出来了:模型、贴图、动画、阴影。但它们都是异步加载的——这引出一个工程问题:页面怎么优雅地等它们? 今天解决三件事:加载态(进度条 + 加载完成才渲染,不让用户看"黑屏/半成品")、资源管理(缓存、释放、成对纪律的系统化)、降级策略(断网/加载失败时回退到几何拼装版,不让车间"崩掉")。这是从"会做 3D"到"会做 3D 工程"的分水岭,也是 Day 103 边界契约的又一次扩展。
目录
一、为什么加载管理是 3D 工程的第一复杂度
1.1 2D vs 3D 的资源差异
3D 工程的第一个复杂度,就是"资源没到齐时怎么办"。不做加载管理,用户会看到:黑屏 → 突然蹦出模型 → 或直接报错。加载态不是锦上添花,是 3D 页面的基本礼仪。
1.2 本周的加载清单
模型(GLB) × 若干台
贴图(map/normalMap) × 每模型 2-3 张
环境贴图(RoomEnvironment 程序生成,无需下载)
动画(随模型内嵌)
二、加载态:进度条 + 加载完成才渲染
2.1 状态机:三态驱动页面
idle(未开始) → loading(加载中) → ready(可用)
└→ error(失败,降级)
2.2 加载管理的核心封装
// src/core/three/asset-manager.ts(资产加载的统一入口)
import * as THREE from "three";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
export type LoadState = "idle" | "loading" | "ready" | "error";
/**
* 资产加载器:统一管理模型/贴图加载与进度
* 核心职责:缓存(同资源只加载一次)+ 进度上报 + 失败降级回调
*/
export class AssetManager {
private manager = new THREE.LoadingManager();
private gltf = new GLTFLoader(this.manager);
private modelCache = new Map<string, THREE.Object3D>();
private total = 0;
private loaded = 0;
/** 加载进度回调(供 UI 进度条使用) */
onProgress: ((pct: number) => void) | null = null;
/** 全部加载完成回调 */
onReady: (() => void) | null = null;
/** 失败回调(触发降级) */
onError: ((url: string) => void) | null = null;
constructor() {
this.manager.onStart = () => this.total++;
this.manager.onProgress = (_url, _item, all) => {
this.loaded = all;
this.onProgress?.((this.loaded / this.total) * 100);
};
this.manager.onLoad = () => this.onReady?.();
this.manager.onError = (url) => this.onError?.(url);
}
/** 加载模型(带缓存:同 URL 只加载一次,克隆复用) */
async getModel(url: string): Promise<THREE.Object3D> {
if (this.modelCache.has(url)) return this.modelCache.get(url)!.clone();
const gltf = await this.gltf.loadAsync(url);
this.modelCache.set(url, gltf.scene);
return gltf.scene.clone();
}
}
进度条的本质:
LoadingManager的onProgress告诉我们"已完成的资源数 / 总数"——这就是"加载进度"的数值来源。Vue 只消费这个数字(见第四节),不接触任何加载细节。
三、异步边界:await 在哪里"汇合"
3.1 关键问题:渲染时机
模型/贴图是异步的,渲染循环不能等它们。正确姿势:资源没齐前不渲染,或先渲染"兜底内容"。
错误姿势:onMounted 里立即 renderer.render
→ 场景空/半成品闪一下,资源到位后"突然跳变"
正确姿势:所有资源 await 汇合 → 一次性进场景 → 再启动渲染循环
→ 用户看到的是"进度条 → 完整车间"
3.2 汇合点设计
// 引擎侧:提供一个"资源就绪后才真正开始"的入口
async function bootstrap(
layout: DeviceLayout[],
asset: AssetManager
): Promise<THREE.Object3D[]> {
// 1. 并行加载所有模型(Promise.all 加速)
const models = await Promise.all(
layout.map((cfg) => asset.getModel(MODEL_URLS[cfg.type]))
);
// 2. 全部就位后才摆位置、挂 id、加入场景
models.forEach((m, i) => {
m.position.set(...layout[i].position);
m.userData.deviceId = layout[i].id;
scene.add(m);
});
return models; // ← 汇合点:返回时资源已齐
}
记忆钩子:异步资源的"汇合点"——所有 await 完成的那一刻,才是把资源放进场景、启动渲染的时机。先等齐,再开画。
四、入向桥扩展:加载进度事件
Day 103 定了 SceneBridge,今天扩展"加载态"通道——Vue 只拿进度数字,不碰加载逻辑:
// SceneBridge 扩展(Day 103 契约 v2):
export interface SceneBridge {
initDevices(config: DeviceLayout[]): void;
updateStates(states: Map<string, DeviceState>): void;
resize(): void;
setAnimation(on: boolean): void;
/** 新增:注册加载进度/状态回调(Vue 侧消费,驱动进度条与降级 UI) */
onLoadState(cb: (s: { state: LoadState; pct: number }) => void): void;
}
<!-- WorkshopView.vue:加载态消费(边界:只读状态数字) -->
<script setup lang="ts">
import { ref } from "vue";
import { onMounted, onUnmounted } from "vue";
const loadingPct = ref(0); // 进度条(0-100)
const loadState = ref<"loading" | "ready" | "error">("loading");
onMounted(() => {
const engine = createInteractiveEngine(canvas.value!);
// 桥:加载进度 → 进度条;就绪 → 显示车间;失败 → 降级提示
engine.bridge.onLoadState((s) => {
loadingPct.value = s.pct;
loadState.value = s.state;
});
// ...engine.start() 等
});
</script>
<template>
<div class="workshop">
<!-- 加载态:进度条(用户等得不焦虑) -->
<div v-if="loadState === 'loading'" class="workshop__loading">
<div class="bar" :style="{ width: loadingPct + '%' }" />
<span>正在装载 3D 车间… {{ Math.floor(loadingPct) }}%</span>
</div>
<!-- 降级提示:加载失败(见第六节) -->
<div v-else-if="loadState === 'error'" class="workshop__error">
模型加载失败,已切换为简化模式
</div>
<canvas ref="canvas" class="workshop__canvas" />
</div>
</template>
边界检查:Vue 侧只有
pct和state两个数字——没有任何 Three 类型泄漏。加载态的"怎么加载"完全在引擎内部(AssetManager),Vue 只是"被通知"。这是 Day 103 契约的又一次兑现。
五、资源管理:缓存、释放、成对纪律
5.1 三类资源的管理纪律
5.2 成对纪律的 3D 完整版(Day 69/100 的延续)
// 引擎 dispose:一次性释放所有 GPU 资源
function disposeAll(): void {
// 1. 停止动画 mixer
mixers.forEach((m) => m.stopAllAction());
// 2. 遍历场景释放几何/材质
scene.traverse((obj) => {
if (obj instanceof THREE.Mesh) {
obj.geometry.dispose();
const mats = Array.isArray(obj.material) ? obj.material : [obj.material];
mats.forEach((m) => m.dispose());
}
});
// 3. 渲染器 + 后处理 + 控制器
renderer.dispose();
composer.dispose();
controls.dispose();
}
记忆钩子:Three 的"谁 new 谁 dispose"——
Geometry、Material、Texture都有.dispose(),是 GPU 资源的"释放键"。组件卸载 = 全量释放,否则显存一路涨(Day 105 验收项)。
六、降级策略:断网/失败 → 几何拼装回退
6.1 为什么必须降级
工业大屏的使用环境:可能内网、可能弱网、模型可能加载失败
→ 车间"崩掉"是不可接受的;回退到几何拼装版是可接受的
降级 = 健壮性。Week 16 红线第 3 条:模型失败回退几何版。
6.2 实现:AssetManager 失败回调 → 拼装兜底
// 引擎里:加载失败时走拼装版(Day 102 buildFanDevice 就是兜底零件库)
function onLoadError(url: string): void {
// 1. 通知 Vue:进入降级模式(显示提示)
emitLoadState({ state: "error", pct: 100 });
// 2. 用几何拼装版兜底(不阻塞,立即有画面)
const fallback = buildFallbackWorkshop(deviceLayout); // Day 102 拼装逻辑
fallback.forEach((g) => scene.add(g));
pickables.push(...fallback);
// 3. 正常启动渲染循环(车间可交互,只是无真实模型)
startLoop();
}
// 完整加载流程:
// try 正常加载 → 成功:真实模型车间
// catch 失败 → 降级:几何拼装车间(不崩)
降级不是"失败"而是"备用方案生效"——页面照常可用,只是质感降档。工业现场的可贵品质。
七、实战:健壮的车间页
把今天全部内容组装成"能扛事的车间":
// src/core/three/scene-engine.ts(Day 111 整合版:加载/降级/释放)
import { AssetManager } from "./asset-manager";
import { buildFallbackWorkshop } from "./build-fallback-workshop";
import { createAtmosphere } from "./create-atmosphere";
import { deviceLayout, MODEL_URLS } from "../config/device-layout";
/**
* 场景引擎(Day 111 整合):加载管理 + 降级 + 氛围 + 交互
* 对外只暴露 bridge/events/start/dispose(Day 103 契约)
*/
export function createSceneEngine(canvas: HTMLCanvasElement) {
// ...场景/相机/渲染器(Day 99-100 汇总)...
// ...氛围装配(Day 109:阴影 + 描边)...
const asset = new AssetManager();
let models: THREE.Object3D[] = [];
async function initDevices() {
asset.onProgress = (pct) => emitLoadState({ state: "loading", pct });
asset.onReady = () => { startLoop(); };
asset.onError = () => {
models = buildFallbackWorkshop(deviceLayout); // 降级
models.forEach((m) => { scene.add(m); pickables.push(m); });
emitLoadState({ state: "error", pct: 100 });
startLoop(); // 降级也照常跑
};
try {
// 正常路径:等模型齐 → 进场景 → onReady 启动
models = await Promise.all(
deviceLayout.map((c) => asset.getModel(MODEL_URLS[c.type]))
);
models.forEach((m, i) => {
m.position.set(...deviceLayout[i].position);
m.userData.deviceId = deviceLayout[i].id;
scene.add(m); pickables.push(m);
});
asset.notifyReadyIfAllDone();
} catch {
asset.triggerError();
}
}
return {
bridge: {
initDevices, // 触发加载(加载态走桥)
onLoadState(cb) { /* 注册回调 */ },
// ...resize/setAnimation/updateStates
},
events: { on(cb) { /* Day 104 出向桥 */ } },
start() { /* 循环 */ },
dispose() { /* 全量释放(第五节) */ },
};
}
观察整条链路:加载 → 进度 → 就绪/失败 → 渲染/降级 → 交互 → 释放——每一步都有明确的"归谁管"(引擎管加载与释放,Vue 管进度与提示)。这就是本周"工程价值"的完整呈现。
八、常见坑点
坑 1:资源没等齐就渲染
症状:黑屏/空场景闪一下,资源到位后突然跳变。修法:await 汇合后再启动循环(第三节)。
坑 2:进度条卡住不动
原因:onProgress 没接、或 total 计算时机不对(onStart 在 onProgress 之后)。修法:按 onProgress 的 all 参数算百分比(第二节)。
坑 3:只缓存模型不释放
症状:反复进出页面显存涨。原因:只做了 modelCache 没做 dispose。修法:卸载时全量遍历释放(第五节)。
坑 4:降级逻辑把主流程搞乱
原因:降级和正常路径写成一坨,if/else 嵌套混乱。修法:正常路径 try / 失败路径 catch,两条路都走 startLoop()(第六节)。
坑 5:Vue 侧偷偷接入了加载逻辑
原因:把 AssetManager 放进 Vue 组件,边界破裂。修法:加载态只通过 onLoadState 桥传数字(第四节)。
九、自测挑战
T1 · 进度条(50 分钟)
接入 AssetManager + 进度条:页面显示"正在装载…x%",加载完成后淡出。用 Network 面板限速(Slow 3G)观察进度条平滑滚动。
T2 · 降级演练(40 分钟)
故意改错模型路径,验证:加载失败 → 提示"简化模式" → 几何拼装车间出现 → 交互照常。亲手制造一次"故障",确认车间不崩。
T3 · 释放验证(40 分钟)
反复进出车间页 30 次,用 Performance/Memory 确认显存不涨。这就是 Day 112 验收的关键证据。
T4 · 边界自检(20 分钟)
对照 Day 103 契约检查:Vue 组件里有没有出现 Three 类型/AssetManager 引用?加载态是否只走桥?边界文档补"加载态"一节。
十、总结
车间"能扛事了":加载有进度、失败有兜底、卸载不泄漏。明天 BOSS 战:把十天的资产拧成 3D 车间 2.0,验收这一周的全部成果。
明日预告:Day 112 BOSS 战——3D 车间 2.0 组装与验收:真实模型 + 贴图质感 + 阴影描边 + 动画 + 加载降级,全效果整合 + 双验收(功能/性能)+ 交付资产(演示/README/简历条目)。