Codex 打通 Three.js 医疗大屏建模与交互

这条链路可行的关键,是让 Codex 通过 Blender MCP 生成可检查的 Blender 场景,再把模型交给 React 与 Three.js 完成医疗数据和交互联动。Codex 拆解任务并根据反馈继续修改,MCP 把指令落实到 Blender,React 管理医疗状态与 DataV 面板,Three.js 负责画布渲染、拾取和镜头。各环节通过模型、业务 ID 和共享状态衔接。

本文从掘金热榜文章《Three.js 医院三维大屏实战:Codex + Blender MCP + React 从建模到交互全流程》切入。现有材料没有该文正文,因此不复述其中无法核对的命令、版本和性能数字。下文依据可核对的资料与实际搭建顺序,说明建模、导出、加载、联动、排障和验收。

Codex、Blender MCP、React 与 Three.js 各自负责什么?

完整链路涉及多个工具,需要按环节划分职责:

Codex
  │ 任务拆解、执行反馈、下一轮修改清单
  ▼
Blender MCP
  │ 查询场景、创建对象、设置变换、材质、保存与导出
  ▼
Blender
  │ 建筑、楼层、科室、床位、设备、材质与层级
  ▼
glTF + 业务清单
  │
  ▼
React 状态层 ─────────────► DataV 图表与详情面板
  │
  ▼
Three.js 场景、材质、显隐、相机与射线拾取
  │
  └──► 浏览器报错、点击结果、筛选结果 ──► 下一轮 Codex 修正
环节 主要职责 不应承担的职责
Codex 拆解需求、生成操作步骤、读取执行结果、整理修改清单 不把聊天回复当作 Blender 场景已经改变的证据
Blender MCP 把查询与修改动作落到 Blender,并返回可检查的状态 不负责 React 页面渲染,也不直接处理医疗业务数据
Blender 生成几何体、层级、材质、UV、灯光和导出文件 不承担实时状态刷新和前端筛选
React 管理模拟数据、REST 或 WebSocket 更新、选中项、筛选条件和组件生命周期 不在每次状态变化时重建整个 Three.js 场景
Three.js 加载模型、渲染画面、射线拾取、相机控制、材质与显隐更新 不直接充当业务状态数据库

React、DataV、Three.js 与 Blender 的组合已有公开实践参考。 Codex 与 Blender MCP 的边界则应通过实际任务逐步验证,而不是依赖一句“AI 自动建模”的概括。

第一次测试也不应该直接搭建整座医院。更合适的最小闭环是:创建名为 MCP_SMOKE_TEST 的测试物体,保存 Blender 场景,导出一个 glTF 文件,在 React 中加载它,点击后回传同一个对象名称。只有这条链路完全闭合,才值得继续加入楼层、床位和设备。

开工前怎样记录环境并跑通最小闭环?

版本基线必须来自当前执行环境,不能从旧文章中拼接。现有资料里的示例同时出现过 Three.js 0.91.0、0.161.0 和 Blender 2.93.6,它们对应不同项目环境,只能作为对照,不能直接当作本次配置。

先采集版本和依赖:

node --version
npm --version
codex --version
codex mcp --help
blender --version
npm ls --depth=0
npm ls three

如果 Blender 没有加入系统路径,就直接运行桌面安装包,并从“关于”页面记录版本。MCP 服务也应记录实际使用的包版本、提交号或配置修订号,因为不同服务与 Codex 客户端的配置入口并不由主题名称决定。

记录项 需要留下的信息
操作系统 系统版本、显卡、浏览器版本
Node 与前端 Node、包管理器、React、Three.js、Vite 或其他构建工具版本
Blender 与 MCP Blender 版本、MCP 服务版本、连接方式、实际端口
Codex 客户端版本、启用的 MCP 服务名称、任务目录
前端服务 开发地址、端口、API 与 WebSocket 地址
文件目录 .blend、导出模型、纹理、清单和构建产物位置
权限 Blender 写入目录、MCP 网络权限、前端静态资源读取权限
样例数据 数据来源、字段、状态数量和更新频率

建议按以下顺序启动:

  1. 启动 Blender,确认当前 MCP 桥接服务已经连接。
  2. 启动 Codex,通过 MCP 查询 Blender,确认能读到场景,而不是只收到“连接成功”。
  3. 启动 React 开发服务,在浏览器中验证空白场景或静态测试模型。
  4. 最后执行创建、保存、导出和点击回传。

开发服务可以这样启动:

npm run dev -- --host 127.0.0.1

端口应以终端实际输出为准,不要把某个开发端口写进文档或部署配置。MCP 连接若走本机网络,优先限制在回环地址,不应直接暴露给局域网或公网。

项目目录最好把源文件、模型和业务清单分开:

medical-dashboard/
├ src/
│  ├ components/
│  │  ├ HospitalScene.jsx
│  │  ├ DetailPanel.jsx
│  │  └ FilterPanel.jsx
│  ├ hooks/
│  │  ├ useHospitalState.js
│  │  └ useSceneSelection.js
│  └ data/
│     └ hospital-fixture.json
├ public/
│  └ models/
│     └ hospital/
│        ├ hospital.gltf
│        ├ hospital.bin
│        ├ textures/
│        └ hospital-manifest.json
└─ scene/
   ├ hospital.blend
   └ exports/

模拟数据先使用匿名对象,避免在模型、截图和日志里带入患者姓名、住院号等信息:

[
  {
    "id": "bed-3f-w-012",
    "name": "BED_3F_W_012",
    "type": "bed",
    "floor": "3F",
    "department": "west",
    "status": "occupied"
  }
]

已有 Blender 模型、React 页面或 DataV 大屏怎么接入?

接入已有项目无需推倒重来,具体方式取决于现有资产。

现有资产 接入方式 优先检查
已有 Blender 模型 先用 MCP 查询层级、命名、单位和材质,再补业务 ID 与导出设置 比例、轴向、轴心、重复材质、无用骨骼、外部纹理
已有 React 页面 将 Three.js 封装成独立组件,由现有页面提供模型地址和业务状态 React 严格模式下的重复初始化、组件卸载后的资源释放
已有 DataV 大屏 把选中项和筛选条件提升到共同的 React 状态层 图表是否自行维护了另一份筛选状态
已有 Three.js 场景 保留现有相机、控制器和加载逻辑,只接入新的医院模型 Three.js 版本差异、拾取对象列表、状态更新入口

最容易出问题的是让 DataV 和 Three.js 各自维护一套筛选条件。正确关系应当是:

React filters
  ├─► 场景对象显隐与材质
  └─► DataV 数据源

Three.js 点击事件只更新 selectedId,DataV 不直接操作 Three.js 对象,场景也不直接改写图表数据。这样才能保证复位、楼层筛选、科室筛选和图表联动使用同一套状态。

Codex 通过 Blender MCP 第一步能验证什么?

第一步不是让 Codex 一次生成完整医院,而是验证每个原子动作是否真的作用到了 Blender。

动作 检查方式 通过标准
查询场景 MCP 返回对象、集合、父子关系与变换 返回内容与 Blender 界面一致
创建物体 在指定 Collection 中新建测试网格 对象名称、类型和位置正确
重命名 修改对象名称 MCP 查询和 Blender 界面显示同一名称
设置父级 将测试对象挂到指定空物体或父对象 移动父级时子对象跟随
调整变换 修改位置、旋转或缩放 世界坐标、包围盒与视觉位置符合预期
赋予材质 添加或替换材质 对象材质槽和预览颜色正确
保存场景 保存 .blend 文件存在,重新打开后修改仍在
导出模型 输出 glTF 及依赖文件 文件完整,React 能加载并拾取对象

可以先给 Codex 一份边界明确的检查任务:

先只读查询当前 Blender 场景,返回 Collection、对象名称、父级、位置、缩放和材质。
不要创建、删除、重命名或导出任何对象。

确认查询结果可信后,再执行单步修改:

在 Hospital 场景的 Test Collection 中创建一个名为 MCP_SMOKE_TEST 的立方体。
不删除现有对象,不修改其他对象,不立即导出。
完成后重新查询场景,并返回新对象的父级、位置和材质。

每轮只改一个变量,比让 Codex 连续执行“建模、材质、灯光、导出、接入 React”更容易定位问题。一个实用的闭环是:

Codex 生成动作
  → MCP 执行
  → 查询 Blender 状态
  → React 加载或浏览器操作
  → 收集报错、截图与点击结果
  → Codex 修正
  → 重新导出并复测

如果 Codex 会话不能直接读取浏览器控制台,就需要把错误文本、Network 面板中的失败请求或拾取结果回填到下一轮任务。Blender MCP 返回“执行成功”并不等于页面正确,最终证据仍应来自场景查询、文件结果和浏览器行为。

医院场景怎样拆到楼层、科室和设备?

医院模型适合按“建筑结构”和“业务对象”两条线组织。Blender 本身覆盖建模、UV、贴图、动画与材质制作,模型进入 Three.js 前的准备流程也有系统资料可参考。

推荐层级如下:

HOSPITAL
├─ Building
│  ├─ BuildingShell
│  └─ Entrances
├─ Floors
│  ├─ Floor_01
│  ├─ Floor_02
│  └─ Floor_03
│     ├─ Department_West
│     │  ├─ Ward_W01
│     │  │  ├─ BED_3F_W_012
│     │  │  └─ BED_3F_W_013
│     │  └─ Device_Group
│     └─ Department_ICU
├─ AlarmAreas
└─ SharedEquipment

这里要区分三种结构:

结构 用途 推荐做法
Collection 方便按楼层、科室或用途选择、隐藏和导出 名称保持稳定
Empty 或父对象 承载移动、旋转和楼层显隐 业务对象尽量归入统一父级
网格对象 真实可见的建筑、床位和设备 绑定唯一 businessId

对象显示名称与业务 ID 应分开。显示名称可以调整,业务 ID 一旦进入接口就不能随意变化,也不要使用 Blender 临时 UUID 或数组下标。命名可以采用:

BED_3F_W_012
DEV_3F_ICU_ECG_007
WARD_3F_NORTH_01

对应清单则记录稳定的业务关系:

{
  "version": 1,
  "objects": [
    {
      "id": "bed-3f-w-012",
      "name": "BED_3F_W_012",
      "type": "bed",
      "floor": "3F",
      "department": "west",
      "pickable": true
    }
  ]
}

建模有两条可行路线。

第一条是使用 Blender 基础几何体搭建低多边形医院。建筑外壳、楼层、科室边界、床位和普通设备先用方体、圆柱与简单曲线表达,适合大屏中远距离观察,也便于控制面数和材质数量。

第二条是导入外部设备模型。外部模型可以提供更细的监护仪、呼吸机或检查设备,但导入后需要检查比例、轴向、轴心、重复几何、UV、材质和纹理。关键设备可以保留细节,重复设备应共享网格与材质,并通过实例化或 LOD 控制运行时成本。

告警区域可以建模成半透明边界或覆盖层,但不要把“红色告警”烘焙进模型。颜色、闪烁和文字属于运行时状态,应由医疗数据在 React 与 Three.js 运行时更新。

glTF 导出前怎样统一坐标、单位和业务 ID?

glTF 适合作为主要交换格式。Three.js 对 glTF 等模型格式提供支持,Blender 模型进入 Three.js 交互展示也有对应实践。

导出前至少检查以下项目:

项目 需要确认的内容
单位 全项目采用同一现实尺度,例如以米建模;不要一处使用米、一处使用厘米
轴向 记录 Blender 场景轴向、导出轴向和运行时观察结果,不在多个环节重复猜测转换
原点与轴心 建筑以地面中心为参考,设备以合理安装点或几何中心为参考
缩放 检查非单位缩放、负缩放和未应用变换,避免导出后比例异常
选择范围 只导出需要进入浏览器的 Collection,避免把辅助物体一并交付
材质 检查透明材质、双面材质、发光材质和重复材质
纹理 使用相对路径或打包方式,核对文件名大小写与实际目录
动画 保留确有需要的骨骼或动画,删除无关动画数据
业务 ID 通过对象自定义属性或旁路清单交付,不能只依赖显示名称
LOD 明确主模型和低细节模型分别用于哪些视距或屏幕尺寸

glTF 导出后,浏览器通常需要同时取得模型、缓冲区与纹理。可以保留便于检查的分离结构:

hospital.gltf
hospital.bin
textures/
  floor_basecolor.png
  device_metal.png
hospital-manifest.json

如果使用打包文件,则要确认打包结果没有改变业务 ID、材质名称或纹理引用。导出完成后,还应把文件重新导入 Blender 做一次往返检查:对象数量、比例、主要材质和层级是否仍然正确。

参考资料列出的 glTF Pipeline、纹理压缩、LOD 和实例化都可以进入候选优化清单,但不应在上线前机械地全部启用。先用渲染统计确认瓶颈,再决定处理顺序。

模型导出后为什么在 React 里加载失败?

加载失败通常不是 Three.js 本身的问题,而是文件、路径、坐标或生命周期中的某一环断了。按下面顺序排查,比直接修改相机和缩放更有效。

现象 优先检查 修正方向
模型请求 404 modelUrl、Vite base、静态目录 统一资源根路径
.bin 或纹理 404 glTF 中的相对路径 同步移动全部依赖文件
CORS 报错 请求地址与页面是否同源 优先同源静态部署,必要时配置服务端代理
MIME 类型错误 Web 服务器对 glTF、bin、图片的配置 使用正确的静态资源响应类型
JSON 或解析错误 文件是否完整、格式是否被二次转换 重新导出,不手工修改二进制内容
模型加载但不可见 包围盒、相机位置、裁剪面、灯光 根据模型包围盒设置相机与阴影范围
模型位置偏移 导出原点、轴心、运行时变换 统一坐标约定,避免重复偏移
材质发黑或丢失 灯光、材质通道、纹理路径 检查材质与灯光,不直接替换成临时颜色
组件卸载后报错 动画循环、事件监听和资源释放 在 React 清理函数中完整释放

React 中的基础场景可以按下面的方式组织。示例只负责初始化、加载、适配与释放,选中状态和医疗数据在后续章节单独接入。

import { useEffect, useRef, useState } from 'react';
import * as THREE from 'three';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';

function disposeMaterial(material) {
  if (!material) return;

  for (const value of Object.values(material)) {
    if (value?.isTexture) value.dispose();
  }

  material.dispose();
}

function disposeModel(root) {
  root.traverse((object) => {
    object.geometry?.dispose();

    const materials = Array.isArray(object.material)
      ? object.material
      : [object.material];

    materials.forEach(disposeMaterial);
  });
}

export function HospitalScene({ modelUrl }) {
  const hostRef = useRef(null);
  const [phase, setPhase] = useState('loading');

  useEffect(() => {
    const host = hostRef.current;
    if (!host) return;

    const scene = new THREE.Scene();
    scene.background = new THREE.Color(0x07131f);

    const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 1000);
    const renderer = new THREE.WebGLRenderer({ antialias: true });
    renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
    renderer.shadowMap.enabled = true;
    renderer.shadowMap.type = THREE.PCFSoftShadowMap;
    renderer.domElement.style.width = '100%';
    renderer.domElement.style.height = '100%';
    host.appendChild(renderer.domElement);

    const hemisphere = new THREE.HemisphereLight(0xffffff, 0x334155, 1.2);
    const keyLight = new THREE.DirectionalLight(0xffffff, 2);
    keyLight.position.set(20, 30, 10);
    keyLight.castShadow = true;
    scene.add(hemisphere, keyLight);

    const controls = new OrbitControls(camera, renderer.domElement);
    controls.enableDamping = true;

    let root = null;
    let disposed = false;

    const resize = () => {
      const width = host.clientWidth;
      const height = host.clientHeight;
      if (!width || !height) return;

      renderer.setSize(width, height, false);
      camera.aspect = width / height;
      camera.updateProjectionMatrix();
    };

    const observer = new ResizeObserver(resize);
    observer.observe(host);
    resize();

    const loader = new GLTFLoader();

    loader.load(
      modelUrl,
      (gltf) => {
        if (disposed) {
          disposeModel(gltf.scene);
          return;
        }

        root = gltf.scene;

        root.traverse((object) => {
          if (object.isMesh) {
            object.castShadow = true;
            object.receiveShadow = true;
          }
        });

        scene.add(root);

        const box = new THREE.Box3().setFromObject(root);
        const sphere = box.getBoundingSphere(new THREE.Sphere());
        const radius = Math.max(sphere.radius, 1);

        controls.target.copy(sphere.center);
        camera.position.copy(sphere.center).add(
          new THREE.Vector3(radius, radius * 0.6, radius)
        );
        camera.near = Math.max(radius / 1000, 0.01);
        camera.far = radius * 100;
        camera.updateProjectionMatrix();
        controls.update();

        setPhase('ready');
      },
      undefined,
      (error) => {
        console.error('Hospital model failed to load:', error);
        if (!disposed) setPhase('error');
      }
    );

    renderer.setAnimationLoop(() => {
      controls.update();
      renderer.render(scene, camera);
    });

    return () => {
      disposed = true;
      renderer.setAnimationLoop(null);
      observer.disconnect();
      controls.dispose();

      if (root) disposeModel(root);

      renderer.dispose();
      renderer.domElement.remove();
    };
  }, [modelUrl]);

  return (
    <div style={{ position: 'relative', width: '100%', height: '100%' }}>
      {phase === 'loading' && <div>模型加载中</div>}

      <div
        ref={hostRef}
        style={{ position: 'absolute', inset: 0 }}
      />

      {phase === 'error' && (
        <div role="alert">
          模型加载失败,已切换到静态数据视图
        </div>
      )}
    </div>
  );
}

加载状态和错误兜底不能只输出到控制台。模型失败时,页面仍应保留筛选器、静态统计和告警列表,避免医疗大屏变成一块空白画布。

医疗状态怎样与三维对象一一对应?

先用模拟数据完成映射,再接入接口。正常、占用、维护和告警可以映射为不同颜色,但不应只依赖颜色。色觉差异、远距离观看和视频截图都会降低单一颜色编码的可辨识度,最好同时提供标签、图标或状态文案。

状态 建议视觉 业务含义
normal 稳定的绿色或中性色 对象当前正常
occupied 蓝色或青色,并显示占用标签 床位正在使用
maintenance 黄色或琥珀色,并显示维护标记 对象暂不可用
alarm 红色,并配合闪烁或告警标识 对象需要处置

颜色属于项目约定,不是资料中规定的医疗标准。实际落地时还应结合医院现有标识规范。

模型加载完成后,先建立业务 ID 与三维对象的映射:

const objectById = new Map();

root.traverse((object) => {
  const id =
    object.userData.businessId ||
    manifestByName.get(object.name)?.id;

  if (id) {
    objectById.set(id, object);
  }
});

状态更新时只修改目标对象:

const STATUS_COLORS = {
  normal: 0x38c793,
  occupied: 0x2f80ed,
  maintenance: 0xf2b84b,
  alarm: 0xeb5757
};

function applyStatus(object, status) {
  if (!object || !STATUS_COLORS[status]) return;

  const materials = Array.isArray(object.material)
    ? object.material
    : [object.material];

  for (const material of materials) {
    material?.color.setHex(STATUS_COLORS[status]);
  }
}

如果多个床位共享同一个材质实例,直接修改材质会让它们一起变色。可交互对象应使用独立材质副本,或者改用 InstancedMesh 的实例颜色通道。

数据接入可以分三步:

  1. 使用本地 fixture 验证 ID、状态和材质映射。
  2. 换成 REST 轮询,确认接口失败、空数据和部分字段缺失时的行为。
  3. 数据频率较高时再接入 WebSocket,并处理断线重连和重复消息。

REST 轮询的关键是替换旧状态,而不是追加状态:

useEffect(() => {
  const controller = new AbortController();
  let stopped = false;

  const load = async () => {
    const response = await fetch('/api/hospital-state', {
      signal: controller.signal
    });

    if (!response.ok) {
      throw new Error(`Hospital state request failed: ${response.status}`);
    }

    const nextState = await response.json();
    if (!stopped) setRecords(nextState);
  };

  load();

  const timer = window.setInterval(load, pollMs);

  return () => {
    stopped = true;
    controller.abort();
    window.clearInterval(timer);
  };
}, [pollMs]);

状态推送也不应触发整棵场景重建。React 保存业务状态,Three.js 在 effect 中比较前后差异,只更新目标对象的颜色、显隐或实例属性。

点击、筛选和镜头联动如何形成完整交互?

Three.js 的射线拾取负责判断用户点中了哪个业务对象,React 负责保存选择结果。指针坐标必须根据画布实际矩形换算,不能直接使用页面坐标:

const pointer = new THREE.Vector2();
const raycaster = new THREE.Raycaster();

function updatePointer(event, canvas) {
  const rect = canvas.getBoundingClientRect();

  pointer.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
  pointer.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
}

function pick(event, camera, pickables) {
  updatePointer(event, event.currentTarget);

  raycaster.setFromCamera(pointer, camera);

  return raycaster.intersectObjects(pickables, false)[0] ?? null;
}

如果模型使用父级 Empty 承载业务对象,命中网格后还要沿父级链查找 businessId。交互建议保持一条清晰路径:

操作 Three.js React DataV
悬停 更新射线结果、鼠标样式和提示位置 记录 hoveredId 不触发
点击 停止继续传播选择逻辑 设置 selectedId 高亮对应业务记录
楼层筛选 设置楼层分组显隐 更新 filters.floor 使用过滤后的数据
科室筛选 设置科室分组显隐 更新 filters.department 使用过滤后的数据
告警筛选 只显示告警对象 更新 filters.alarmOnly 只统计告警数据
镜头聚焦 平移相机与控制器目标 保存焦点对象 联动详情面板
复位 恢复初始相机和控制器目标 清空筛选与选择 恢复完整图表

镜头动画可以接入 GSAP;参考资料也把 GSAP 用于复杂相机路径与时间轴动画。

gsap.to(camera.position, {
  x: target.position.x,
  y: target.position.y,
  z: target.position.z,
  duration: 0.6,
  ease: 'power2.out'
});

gsap.to(controls.target, {
  x: target.position.x,
  y: target.position.y,
  z: target.position.z,
  duration: 0.6,
  ease: 'power2.out'
});

更稳妥的实现是让相机围绕目标计算一个偏移位置,而不是直接飞到对象原点。对于内部床位,镜头目标可以取床位中心;对于楼层筛选,则可以取该楼层所有可见对象的包围盒中心。

高亮也不建议永久改写共享材质。可以为选中对象克隆材质、设置单独的选中态材质,或者使用辅助框。详情面板只根据 selectedId 从业务状态中读取数据,不从 Three.js 对象里拼装医疗信息。

1920×1080 大屏怎样适配?

固定设计尺寸适合会议室或指挥中心的大屏。已有 Three.js 医疗监控示例直接按 1920×1080 建立画布,并通过固定相机、灯光和雾效组织场景。

常见做法是保留一个 1920×1080 的设计舞台,再按容器比例整体缩放:

const DESIGN_WIDTH = 1920;
const DESIGN_HEIGHT = 1080;

function fitStage(container, stage) {
  const scale = Math.min(
    container.clientWidth / DESIGN_WIDTH,
    container.clientHeight / DESIGN_HEIGHT
  );

  stage.style.transform =
    `translate(-50%, -50%) scale(${scale})`;
}
.stage-host {
  position: relative;
  width: 100%;
  height: 100%;
  overflow: hidden;
}

.stage {
  position: absolute;
  left: 50%;
  top: 50%;
  width: 1920px;
  height: 1080px;
  transform-origin: center center;
}

这种方式能保持 DataV 面板、Three.js 画布与三维文字的位置关系,不会在不同屏幕上逐个拉伸。浏览器比例不是 16∶9 时,应保留留白,而不是强行铺满。

如果页面需要同时适配桌面、笔记本和不同分辨率大屏,则应使用 CSS Grid 配合 ResizeObserver,让 Three.js 画布跟随面板尺寸变化。此时要同步更新相机宽高比和渲染器尺寸。

固定舞台和自适应布局都需要处理以下问题:

  • 固定设计尺寸不等于固定渲染分辨率。
  • 高像素密度屏幕会放大实际渲染像素数量,需要限制 renderer.setPixelRatio()。
  • Three.js 画布、CSS 面板和拾取坐标必须使用同一套布局。
  • 缩放后的指针位置仍应通过 getBoundingClientRect() 换算。
  • 全屏切换后要触发一次 resize,并重新检查 DataV 图表尺寸。

大屏卡顿、错位或内存上涨怎么排?

性能排查应从渲染统计、React 更新和资源生命周期三条线同时进行。只看模型面数不够,因为大量低面数网格同样可能制造很高的 draw calls。

可以在稳定帧读取 Three.js 统计:

console.table({
  calls: renderer.info.render.calls,
  triangles: renderer.info.render.triangles,
  geometries: renderer.info.memory.geometries,
  textures: renderer.info.memory.textures
});
现象 重点测量 常见修正
帧率低但模型面数不高 draw calls、材质数量、阴影 合并静态几何、减少透明材质、限制阴影范围
大量床位拖慢场景 实例数量与重复网格 使用 InstancedMesh
首次加载慢 glTF、纹理与请求瀑布图 纹理压缩、分块或渐进加载
缩放时明显卡顿 像素比、阴影和高精度纹理 控制渲染像素比,按距离降低细节
筛选后帧率短暂下降 是否重建模型和材质 只更新目标对象属性
点击位置偏移 指针坐标与画布矩形 使用 getBoundingClientRect() 换算
内存持续上涨 geometry、texture、材质、事件监听 卸载时释放资源并解绑事件
多次进入页面后越来越卡 是否重复创建 renderer renderer 只初始化一次并完整清理

参考资料列出的 LOD、InstancedMesh、glTF Pipeline、纹理压缩、渐进加载和 Points 配合 ShaderMaterial,都可以按实际瓶颈选择使用。 但这些方法解决的是不同问题:LOD 主要控制几何细节,实例化主要减少重复对象调用,纹理压缩主要降低资源体积,渐进加载改善的是加载过程,Points 则适合不承担实体表面的点状数据。

React 侧还要检查两个常见问题:一是把 Three.js 对象直接放进 state,导致每次业务更新都触发大范围渲染;二是 effect 依赖数组频繁变化,使场景被反复销毁和重建。场景对象应保存在 ref 中,业务状态只保存 ID、筛选条件和必要的显示参数。

资源释放至少包括:

geometry.dispose();
material.dispose();
texture.dispose();
renderer.dispose();
controls.dispose();

同时移除 resize、pointermove、click 和 WebSocket 监听器。阴影贴图、渲染目标以及后处理 Pass 使用的资源也要单独检查。

最终怎样验收并安全上线?

验收不能只写“页面正常”或“效果不错”,每项都应留下能够复查的证据。

验收模块 测试动作 通过标准与证据
MCP 最小闭环 查询、创建、重命名、保存、导出 返回结果与 Blender 场景一致,导出文件存在
模型加载 刷新 React 页面 模型出现,Network 中模型及纹理请求成功
ID 映射 点击床位和设备 回传 ID 与业务清单一致
状态更新 切换正常、占用、维护和告警数据 目标对象更新,其他对象不受影响
悬停与点击 划过楼层、科室和设备 提示、选中态与详情面板正确
楼层与科室筛选 组合多个筛选条件 场景显隐与 DataV 数据一致
镜头聚焦 点击设备并复位 镜头到达目标,复位恢复初始视角
DataV 同步 选择对象并切换图表条件 图表与场景使用同一筛选结果
错误兜底 临时使用无效模型地址 页面显示错误或静态视图,不出现空白页
全屏适配 在不同窗口比例下切换全屏 面板不重叠,拾取位置不偏移
资源释放 多次进入并离开场景页面 不重复创建 renderer,内存不持续增长

构建与本地复查可以这样执行:

npm run build
npm run preview -- --host 127.0.0.1

非功能项也要留下原始记录:

  • 构建命令的退出状态和产物目录。
  • 浏览器控制台错误与警告。
  • Network 中失败或异常的模型、纹理和接口请求。
  • 固定浏览器、窗口尺寸、像素比和采样时长下的帧率记录。
  • JavaScript 堆、GPU 资源和 Three.js geometry、texture 数量。
  • glTF、bin、纹理、脚本与总资源体积。
  • 模型面数、材质数、draw calls 和初始加载时间。

安全检查应围绕演示数据和部署边界展开。样例数据只保留床位、设备、楼层、科室和状态,不包含患者姓名、住院号、诊断或联系方式;接口与静态资源部署不能携带密钥;Blender MCP 只开放给受信任的本机进程;访问日志不记录完整业务载荷。React 与 Three.js 组合已有实践可供参考,但生产环境仍需单独完成权限和数据检查。

现有材料没有附上这次构建的终端输出、浏览器控制台、帧率、内存或资源体积,因此本文不把这些项目写成已经通过的数值。建模、导出、加载、状态映射、交互、性能和部署检查已有明确顺序,最终发布状态仍须由实际环境中的构建产物、浏览器复查和运行记录确认。

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容