接上篇(一)的架构与 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.fromScene 把 RoomEnvironment 烘焙成一张预过滤环境贴图(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.environment | PMREMGenerator.fromScene(RoomEnvironment) 注入 |
| 纹理看不见 | sRGB 错 + metalness 过高 + UV 不 repeat | colorSpace 声明 + body 降 metalness + repeat(2,2) |
| 重复上传/串色 | StrictMode 双调用 + 共享 GLB | applied ref + clone(true) |
下一篇(三)讲后端:Go + gorilla/websocket 怎么把遥测实时广播给所有浏览器,以及 CheckOrigin 为什么放行 *.vercel.app,还有镜像构建与自动部署。