← 返回博客
前端2026-08-31 13:26:005 分钟 · 1,323 0

工业数字孪生实战(二):前端 3D 实时渲染与纹理修复实录

接上篇(一)的架构与 CI/CD,这一篇钻到前端渲染层。数字孪生的"面子"就是 3D 画面:浏览器里得把 CNC、注塑、装配这些设备渲染出来,纹理清晰、金属有质感、状态能实时变色。

接上篇(一)的架构与 CI/CD,这一篇钻到前端渲染层。数字孪生的"面子"就是 3D 画面:浏览器里得把 CNC、注塑、装配这些设备渲染出来,纹理清晰、金属有质感、状态能实时变色。

技术栈是 Next.js + React Three Fiber(R3F)+ @react-three/drei + three 0.171。R3F 把 three 的命令式 API 封装成声明式 JSX,写 3D 像写组件;drei 提供 useGLTF / useTexture / OrbitControls 等开箱即用的钩子。但封装层之下,PBR 材质和色彩空间的坑一个不少。下面三个坑都是我们上线前真踩过的。

一、渲染骨架:Canvas 与场景环境

入口是 TwinCanvas(必须 dynamic(ssr:false) 引入,因为 WebGL 只在浏览器跑,SSR 会炸):

// web/components/twin/TwinCanvas.tsx
<Canvas
  dpr={[1, 2]}
  camera={{ position: [24, 20, 24], fov: 45 }}
  gl={{ antialias: true }}
  onCreated={({ scene, gl }) => {
    scene.background = new THREE.Color("#04060d");
    scene.fog = new THREE.Fog("#04060d", 45, 110);
    if (gl) {
      scene.environment = buildRoomEnvMap(gl).texture; // 关键:注入环境贴图
    }
  }}
>
  <ambientLight intensity={0.5} />
  <directionalLight position={[18, 30, 12]} intensity={1.1} />
  <Scene />
  <OrbitControls maxPolarAngle={Math.PI / 2.2} minDistance={10} maxDistance={70} enablePan={false} />
</Canvas>

dpr={[1,2]} 让高分屏更锐利又不过载;背景深空蓝 + 雾营造工厂纵深感。

二、坑一:金属件发黑 / 发死白——缺 envMap

模型用的是 MeshStandardMaterial(PBR 物理材质)。PBR 材质要算反射,反射来自环境贴图 scene.environment。最初我们没注入环境贴图,结果 metalness 调到 0.6+ 的金属表面要么纯黑(无反射源),要么死白(只有直接光照被金属反射)。屏幕上一片"铁疙瘩黑洞",惨不忍睹。

修复用 three 自带的程序化环境,零网络下载:

// web/lib/twin/envMap.ts
import { PMREMGenerator } from "three";
import { RoomEnvironment } from "three/examples/jsm/environments/RoomEnvironment.js";

export function buildRoomEnvMap(gl: WebGLRenderer): RoomEnvMap {
  const pmrem = new PMREMGenerator(gl);
  const env = new RoomEnvironment();          // 0.171 起无参构造
  const target = pmrem.fromScene(env, 0.04);  // 0.04 控制反射模糊度(米)
  return {
    texture: target.texture,
    dispose: () => { target.dispose(); pmrem.dispose(); },
  };
}

PMREMGenerator.fromSceneRoomEnvironment 烘焙成一张预过滤环境贴图(PMREM = 预滤波 mipmap 辐射环境),挂到 scene.environment 后,所有 MeshStandardMaterial 自动获得柔和的反射。这一步之后金属不再发死白,而是有了"磨砂不锈钢"的质感。

三、坑二:纹理根本看不见——sRGB、metalness、UV 三连坑

我们有三套 PolyHaven 离线 PBR 纹理(金属板 / 厂房墙 / 蓝金属板),按设备类型分配:

// web/lib/twin/textures.ts
export const TEX_KINDS = {
  cnc:      { map: "/textures/metal_plate_diff.jpg",  normalMap: "/textures/metal_plate_nor.jpg",  roughnessMap: "/textures/metal_plate_rough.jpg" },
  molder:   { map: "/textures/factory_wall_diff.jpg",  normalMap: "/textures/factory_wall_nor.jpg",  roughnessMap: "/textures/factory_wall_rough.jpg" },
  assembler: { map: "/textures/blue_metal_plate_diff.jpg", normalMap: "/textures/blue_metal_plate_nor.jpg", roughnessMap: "/textures/blue_metal_plate_rough.jpg" },
};

贴上去后画面还是一片纯色——纹理去哪了?三个独立原因叠加:

1. 色彩空间错了:map 必须是 sRGB

diffuse map 是"颜色",要按 sRGB 解读;normalMap / roughnessMap 是"数据",必须线性。drei 的 useTexture 底层走 TextureLoader,默认 NoColorSpace,会让 map 偏暗偏色。显式声明:

export function colorSpaceFor(slot: TexSlot): string {
  return slot === "map" ? SRGBColorSpace : NoColorSpace;
}
tex.colorSpace = colorSpaceFor(slot);

2. metalness 太高:贴图被反射吃掉

MeshStandardMaterial 里,漫反射(贴图颜色)占比 = 1 - metalness。原 body 金属度 0.7,意味着 70% 走反射、贴图只剩 30% 可见,再叠加 envMap 就几乎看不见花纹了。把 body 降到磨砂涂装:

// body 节点专属覆写:让贴图本色显示
export const BODY_OVERRIDE = { color: "#b8c4d0", metalness: 0.18, roughness: 0.6 };

保留 0.18 的金属度,envMap 仍能反一点光(有金属感),但 diffuse map 主导颜色,菱形花纹肉眼可见。

3. UV 不 repeat:一面只有一格

BoxGeometry 的 UV 默认 0~1 铺满整个面。设备按 ~2m 真实尺寸铺设,需要平铺:

export const TEX_REPEAT: [number, number] = [2, 2];
tex.wrapS = RepeatWrapping;
tex.wrapT = RepeatWrapping;
tex.repeat.set(TEX_REPEAT[0], TEX_REPEAT[1]);

统一遍历写入(纯函数 + 显式栈)

GLB 是一棵树,遍历所有 mesh 写材质。用显式栈避免深树递归爆栈:

export function applyTexturesToDevice(root, bodyNode, textures): void {
  if (!root) return;
  const stack = [root];
  while (stack.length > 0) {
    const node = stack.pop()!;
    if (node.material && shouldTextureMaterial(node.material.name)) {
      applyTexturesToMaterial(node.material, textures);
    }
    if (node.name === bodyNode) applyBodyOverride(node.material);
    if (node.children) for (let i = node.children.length - 1; i >= 0; i--) stack.push(node.children[i]);
  }
}

shouldTextureMaterial 跳过 Screen/Light/Accent 这类发光件——它们应保持自身品牌色,不吃状态纹理。

四、坑三:StrictMode 双调用 → 重复 GPU 上传

React 18 开发模式 StrictMode 会故意双调用 effect。我们最初在 useEffect 里直接 applyTexturesToDevice,结果每个 mesh 的纹理被写两次,开发控制台狂报 GPU 资源重复上传。一个 applied ref 搞定:

// web/components/twin/GlbDevice.tsx
const applied = useRef(false);
useEffect(() => {
  if (applied.current) return;
  if (!texConfig || !textures?.[0]) return;
  applyTexturesToDevice(root, bodyNode, [textures[0], textures[1], textures[2]]);
  applied.current = true;
}, [root, bodyNode, texConfig, textures]);

五、GLB + Draco 压缩加载

模型文件用 Draco 压缩(体积砍掉一大半),加载器要指定解码器路径。drei 的 useGLTF 第二参即 Draco 路径:

const DRACO_PATH = "/draco/";
const gltf = useGLTF(url, DRACO_PATH);
const root = useMemo(() => (gltf.scene.clone?.(true) ?? gltf.scene), [gltf.scene]);
// 启动期预拉纹理,避免首屏白块
useTexture.preload(PRELOAD_URLS);

clone(true) 很关键:GLB 是多设备共享的,克隆出独立实例才能给每台设备单独着色,互不串色。设备类型 → 模型由 models.ts::resolveModel 做唯一开关点,显式 NEXT_PUBLIC_GLB=0 可退回占位体。

六、设备状态实时发光

后端推 tick 帧(每设备最新指标),前端把"状态色"写到 body 的 emissive 通道。每帧 lerp 平滑过渡、脉冲强度趋近目标,避免硬切闪烁:

// web/components/twin/DeviceMesh.tsx
export function advanceEmissive(mat, status, selected, t) {
  tmpColor.set(targetEmissive(status));
  mat.emissive.lerp(tmpColor, 0.15);                  // 颜色平滑过渡
  const target = targetIntensity(status, t) * (selected ? 1.4 : 1);
  mat.emissiveIntensity += (target - mat.emissiveIntensity) * 0.2; // 脉冲趋近
}

性能细节:只订阅"当前设备"的状态(zustand selector),全量 metrics tick 不会触发所有设备重渲染;useFrame 内零对象分配(复用模块级 tmpColor)。

七、WS 地址:三级回落,别静默连错

前端连后端 WS,地址解析优先级(见 web/lib/connect.ts::resolveWsUrl):

function resolveWsUrl(): string {
  const fromEnv = process.env.NEXT_PUBLIC_WS_URL;
  if (fromEnv) return fromEnv;                       // 1) 显式环境变量(最高优先级)
  const isLocal = window.location.hostname === "localhost" || "127.0.0.1";
  if (isLocal) return "/ws/";                        // 2) 本地 dev:走相对路径(dev server 代理)
  console.warn("[twin] 回落到 wss://twin-api.wxbuluo.com/ws/");
  return "wss://twin-api.wxbuluo.com/ws/";           // 3) 生产未配置:回落固定 WS 子域
}

这套回落救过一次生产:Vercel 上忘了设 NEXT_PUBLIC_WS_URL,代码自动连到 twin-api.wxbuluo.com,而不是静默连到 <project>.vercel.app(Vercel 无 WS 服务,必然失败)。

小结:前端渲染三连坑

现象根因修法
金属发黑/死白scene.environmentPMREMGenerator.fromScene(RoomEnvironment) 注入
纹理看不见sRGB 错 + metalness 过高 + UV 不 repeatcolorSpace 声明 + body 降 metalness + repeat(2,2)
重复上传/串色StrictMode 双调用 + 共享 GLBapplied ref + clone(true)

下一篇(三)讲后端:Go + gorilla/websocket 怎么把遥测实时广播给所有浏览器,以及 CheckOrigin 为什么放行 *.vercel.app,还有镜像构建与自动部署。

相关推荐

本文为原创文章,采用CC BY-NC-SA 4.0协议授权,转载请保留署名与原文链接。原文链接:https://www.wxbuluo.com/article/161