【Three.js】day111-asset-integration

作者:mario 发布时间: 2026-09-07 阅读量:5 评论数:0

Day 111 · 资产集成与边界 — 加载态、资源管理与降级

前九天你把资产做出来了:模型、贴图、动画、阴影。但它们都是异步加载的——这引出一个工程问题:页面怎么优雅地等它们? 今天解决三件事:加载态(进度条 + 加载完成才渲染,不让用户看"黑屏/半成品")、资源管理(缓存、释放、成对纪律的系统化)、降级策略(断网/加载失败时回退到几何拼装版,不让车间"崩掉")。这是从"会做 3D"到"会做 3D 工程"的分水岭,也是 Day 103 边界契约的又一次扩展。


目录


一、为什么加载管理是 3D 工程的第一复杂度

1.1 2D vs 3D 的资源差异

维度

2D 大屏

3D 车间

资源

数据(WS 秒级)

模型/贴图/HDR(可能几十 MB)

加载

几乎无感

可能几秒,期间场景是空的

失败

重连即可

模型缺失 = 画面崩坏

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();
  }
}

进度条的本质LoadingManageronProgress 告诉我们"已完成的资源数 / 总数"——这就是"加载进度"的数值来源。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 侧只有 pctstate 两个数字——没有任何 Three 类型泄漏。加载态的"怎么加载"完全在引擎内部(AssetManager),Vue 只是"被通知"。这是 Day 103 契约的又一次兑现。


五、资源管理:缓存、释放、成对纪律

5.1 三类资源的管理纪律

资源

缓存

释放

模型(GLB)

modelCache 只加载一次,克隆复用

geometry.dispose() 各 Mesh

贴图

每张只加载一次

texture.dispose()

环境贴图

全局一份

envMap.dispose()

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"——GeometryMaterialTexture 都有 .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 之后)。修法:按 onProgressall 参数算百分比(第二节)。

坑 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 引用?加载态是否只走桥?边界文档补"加载态"一节


十、总结

环节

要点

加载管理

模型/贴图异步 → 进度条 + 等齐再渲染(先等齐再开画)

状态机

idle → loading → ready / error(三态驱动 UI)

异步边界

await 汇合点 = 资源就绪那一刻才进场景

桥扩展

onLoadState 只传数字(pct/state)——Vue 不碰加载细节

资源管理

谁 new 谁 dispose:几何/材质/贴图/envMap 全量释放

降级策略

失败 → 几何拼装回退 + 提示 + 照常交互(不崩)

车间"能扛事了":加载有进度、失败有兜底、卸载不泄漏。明天 BOSS 战:把十天的资产拧成 3D 车间 2.0,验收这一周的全部成果。


明日预告:Day 112 BOSS 战——3D 车间 2.0 组装与验收:真实模型 + 贴图质感 + 阴影描边 + 动画 + 加载降级,全效果整合 + 双验收(功能/性能)+ 交付资产(演示/README/简历条目)。

评论