fix: render Claw models without waiting for external HDR lighting

This commit is contained in:
134 2026-09-15 16:20:23 +08:00 committed by root
parent 3bf0fec097
commit 73593051fe
7 changed files with 180 additions and 8 deletions

View File

@ -1,13 +1,15 @@
"use client";
/* eslint-disable @next/next/no-img-element */
import { Environment, OrbitControls, useGLTF } from "@react-three/drei";
import { OrbitControls, useGLTF } from "@react-three/drei";
import { Canvas, useFrame, useThree } from "@react-three/fiber";
import { AlertCircle } from "lucide-react";
import React, { Suspense, useEffect, useMemo, useRef } from "react";
import * as THREE from "three";
import type { Mesh, Object3D } from "three";
import type { OrbitControls as OrbitControlsImpl } from "three-stdlib";
import { LocalStudioEnvironment } from "@/components/canvas/model3d/LocalStudioEnvironment";
import { ModelLoadingFallback } from "@/components/canvas/model3d/ModelLoadingFallback";
import type {
OpenClawModelInteractionManifest,
OpenClawModelInteractionPart,
@ -483,7 +485,6 @@ function ModelInteractionScene({
position={fitTransform.position}
scale={fitTransform.scale}
/>
<Environment preset="studio" />
<OrbitControls
dampingFactor={0.08}
enableDamping
@ -559,7 +560,7 @@ export function OpenClawInteractiveModelViewer({
) : null}
<div className="relative flex max-w-xs flex-col items-center gap-2 rounded-md bg-black/70 px-4 py-3 text-center backdrop-blur-sm">
<AlertCircle className="h-6 w-6 text-amber-300" />
<p className="text-xs">3D 交互预览加载失败,已显示静态预览</p>
<p className="text-xs">{previewImageUrl ? "3D 交互预览加载失败,可查看静态预览或下载模型" : "3D 交互预览加载失败,请退出预览后重试或下载模型"}</p>
</div>
</div>
}
@ -572,7 +573,8 @@ export function OpenClawInteractiveModelViewer({
shadows
style={{ background }}
>
<Suspense fallback={null}>
<LocalStudioEnvironment />
<Suspense fallback={<ModelLoadingFallback />}>
<ModelInteractionScene
allowDragging={allowDragging}
fitExplodedSource={fitExplodedSource}

View File

@ -79,6 +79,7 @@ function ViewerLoading() {
return (
<div className="flex h-full w-full items-center justify-center bg-zinc-950 text-zinc-400">
<Loader2 className="h-6 w-6 animate-spin" />
<span className="ml-2 text-xs">正在准备 3D 预览…</span>
</div>
);
}
@ -658,6 +659,11 @@ export function OpenClawModel3DPreview({
messageId,
artifactIndex: interactionManifestIndex,
});
const timeout = setTimeout(() => {
controller.abort();
setManifest(null);
setManifestError("交互清单加载超时");
}, 15000);
setManifestError(null);
fetch(manifestUrl, { signal: controller.signal })
.then(async (response) => {
@ -676,8 +682,12 @@ export function OpenClawModel3DPreview({
setManifestError(
error instanceof Error ? error.message : "交互清单读取失败"
);
});
return () => controller.abort();
})
.finally(() => clearTimeout(timeout));
return () => {
clearTimeout(timeout);
controller.abort();
};
}, [interactionManifestIndex, chatId, isActive, manifest, messageId]);
useEffect(() => {
@ -1082,6 +1092,7 @@ export function OpenClawModel3DPreview({
) : (
<Model3DViewer
autoFitModel
localStudioEnvironment
modelFormat={model.viewer?.format || "glb"}
modelUrl={modelUrl}
previewImageUrl={previewUrl}

View File

@ -0,0 +1,31 @@
"use client";
import { useThree } from "@react-three/fiber";
import { useLayoutEffect } from "react";
import { PMREMGenerator } from "three";
import { RoomEnvironment } from "three/addons/environments/RoomEnvironment.js";
// Generate studio reflections locally: an unavailable HDR CDN must not hide GLB models.
export function LocalStudioEnvironment() {
const { gl, scene } = useThree();
useLayoutEffect(() => {
const room = new RoomEnvironment();
const generator = new PMREMGenerator(gl);
const previous = scene.environment;
let target: ReturnType<PMREMGenerator["fromScene"]> | undefined;
try {
target = generator.fromScene(room, 0.04);
scene.environment = target.texture;
} catch {
// Direct lights still render the model on devices unable to build a PMREM.
} finally {
room.dispose();
generator.dispose();
}
return () => {
if (scene.environment === target?.texture) scene.environment = previous;
target?.dispose();
};
}, [gl, scene]);
return null;
}

View File

@ -16,6 +16,8 @@ import type { Mesh, Object3D } from "three";
import { KTX2Loader } from "three-stdlib";
import { Button } from "@/components/ui/button";
import { buildThreeAssetUrl } from "@/lib/canvas/three-asset-url";
import { LocalStudioEnvironment } from "./LocalStudioEnvironment";
import { ModelLoadingFallback } from "./ModelLoadingFallback";
export type Model3DLayer = {
id: string;
@ -55,6 +57,7 @@ type Model3DViewerProps = {
modelUrl?: string | null;
modelFormat?: "glb" | "gltf" | null;
autoFitModel?: boolean;
localStudioEnvironment?: boolean;
previewImageUrl?: string | null;
emptyStateTitle?: string;
settings: Model3DViewerSettings;
@ -721,6 +724,7 @@ export function Model3DViewer({
modelUrl,
modelFormat,
autoFitModel = false,
localStudioEnvironment = false,
previewImageUrl,
emptyStateTitle = "准备生成 3D 资产",
settings,
@ -789,7 +793,7 @@ export function Model3DViewer({
) : null}
<div className="relative flex max-w-xs flex-col items-center gap-3 rounded-lg bg-black/70 px-4 py-3 text-center backdrop-blur-sm">
<AlertCircle className="h-7 w-7 text-amber-300" />
<p className="text-sm">3D 预览加载失败,已切换为静态预览</p>
<p className="text-sm">{previewImageUrl ? "3D 预览加载失败,可查看静态预览或下载模型" : "3D 预览加载失败,请重试或下载模型"}</p>
<Button
asChild
className="gap-2 border-white/10 text-zinc-300 hover:bg-white/5"
@ -851,8 +855,10 @@ export function Model3DViewer({
/>
) : null}
{localStudioEnvironment ? <LocalStudioEnvironment /> : null}
<Suspense
fallback={
localStudioEnvironment ? <ModelLoadingFallback /> :
<mesh>
<boxGeometry args={[0.5, 0.5, 0.5]} />
<meshStandardMaterial color="#27272a" wireframe />
@ -880,8 +886,12 @@ export function Model3DViewer({
scale={10}
/>
</group>
<Environment preset={settings.envPreset} />
</Suspense>
{!localStudioEnvironment ? (
<Suspense fallback={null}>
<Environment preset={settings.envPreset} />
</Suspense>
) : null}
<OrbitControls
autoRotate={settings.autoRotate}
dampingFactor={0.08}

View File

@ -0,0 +1,21 @@
"use client";
import { Html } from "@react-three/drei";
import { Loader2 } from "lucide-react";
import { useEffect, useState } from "react";
export function ModelLoadingFallback() {
const [slow, setSlow] = useState(false);
useEffect(() => {
const timer = setTimeout(() => setSlow(true), 15000);
return () => clearTimeout(timer);
}, []);
return (
<Html center>
<div role="status" className="flex w-60 flex-col items-center gap-2 rounded-lg bg-zinc-900/90 px-4 py-3 text-center text-xs text-white">
<Loader2 className="h-5 w-5 animate-spin" />
<p>{slow ? "模型加载较慢,可退出预览后重试,或下载模型查看" : "正在加载 3D 模型…"}</p>
</div>
</Html>
);
}

View File

@ -0,0 +1,24 @@
# Claw 3D 预览黑屏(2026-09-15)
## 已复现的原因
交互查看器与基础查看器都使用 Drei 的远程环境预设。当前依赖版本会向 `raw.githack.com` 下载 HDR 灯光贴图,且模型与环境处于同一 Suspense 边界。即使 GLB 已返回,环境请求一直等待也会隐藏整个模型;交互查看器的等待占位原为 null,表现为黑色空白。
将外部网络请求挂起、GLB 正常返回的 Chrome WebGL 测试中,旧版两种查看器均未完成挂载,画布可见模型像素为 0。修复后两个查看器都完成挂载并渲染模型,灯光不再发起外部请求。已用此前故障排查保存的真实家具 GLB 验证两种查看器均显示正常。未取得本次反馈用户的浏览器网络记录,不能将模拟结果当作该用户的实测网络错误。
线上检查时,9 月 15 日检索到的 76 次 Claw 附件请求全部返回 HTTP 200;这不能证明用户浏览器访问外部环境资源也成功。
## 修改
- Claw 两种查看器使用本地 RoomEnvironment/PMREM 生成灯光反射,不再等待外部 HDR。生成失败时保留直接灯光,卸载时释放 GPU 资源。
- 其他 3D 页面的环境预设选项保留,环境与模型分别使用 Suspense,环境等待不再阻止模型显示。
- GLB 等待时显示文字提示,15 秒后提示加载较慢;明确区分失败与静态预览。
- 交互清单等待超过 15 秒后取消请求,切换到基础 3D 查看器,保留清单超时提示。
## 验证
`node scripts/verify-claw-3d-loading.mjs` 使用实际组件、Chrome WebGL 和本地合成 GLB,验证外部请求不可用、慢模型加载提示、交互清单超时及模型 HTTP 失败提示。
`--source-ref=54c857d7` 可复现旧查看器因外部请求挂起而空白;`--model-file=<local GLB>` 可检查本地真实模型,不上传文件。
15 项 `tests/routes/openclaw-3d-artifacts.test.ts` 测试通过;TypeScript 检查通过。浏览器测试未调用生产生成功能、未修改业务数据,真实模型文件不入库。

View File

@ -0,0 +1,73 @@
// Real WebGL regression with a synthetic GLB; no production data or generation calls.
// node scripts/verify-claw-3d-loading.mjs [--source-ref=<revision>] [--model-file=<local GLB>]
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import path from 'node:path';
import os from 'node:os';
import http from 'node:http';
import { execFileSync } from 'node:child_process';
import { createRequire } from 'node:module';
import { BoxGeometry } from 'three';
import { chromium } from '@playwright/test';
const require = createRequire(import.meta.url);
const esbuild = require(require.resolve('esbuild', { paths: [require.resolve('tsx')] }));
const sharp = require('sharp');
const root = process.cwd();
const ref = process.argv.find(x => x.startsWith('--source-ref='))?.slice(13);
const modelFile = process.argv.find(x => x.startsWith('--model-file='))?.slice(13);
const temp = await fs.mkdtemp(path.join(os.tmpdir(), 'claw-3d-loading-'));
const geometry = new BoxGeometry(1, 1, 1);
const arrays = [geometry.attributes.position.array, geometry.attributes.normal.array, geometry.index.array];
let offset = 0;
const views = arrays.map(a => { const v = {buffer: 0, byteOffset: offset, byteLength: a.byteLength}; offset += a.byteLength; return v; });
const bin = Buffer.concat(arrays.map(a => Buffer.from(a.buffer, a.byteOffset, a.byteLength)));
const json = { asset: {version: '2.0'}, scene: 0, scenes: [{nodes: [0]}], nodes: [{mesh: 0, name: 'panel'}], meshes: [{primitives: [{attributes: {POSITION: 0, NORMAL: 1}, indices: 2, material: 0}]}], materials: [{pbrMetallicRoughness: {baseColorFactor: [0.65,0.72,0.8,1], metallicFactor: 0.4, roughnessFactor: 0.35}}], buffers: [{byteLength: bin.length}], bufferViews: views, accessors: [{bufferView: 0, componentType: 5126, count: 24, type: 'VEC3', min: [-0.5,-0.5,-0.5], max: [0.5,0.5,0.5]}, {bufferView: 1, componentType: 5126, count: 24, type: 'VEC3'}, {bufferView: 2, componentType: 5123, count: 36, type: 'SCALAR'}] };
const raw = Buffer.from(JSON.stringify(json));
const padded = Buffer.concat([raw, Buffer.alloc((4 - raw.length % 4) % 4, 32)]);
const header = Buffer.alloc(20); header.writeUInt32LE(0x46546c67, 0); header.writeUInt32LE(2,4); header.writeUInt32LE(28 + padded.length + bin.length,8); header.writeUInt32LE(padded.length,12); header.writeUInt32LE(0x4e4f534a,16);
const binHeader = Buffer.alloc(8); binHeader.writeUInt32LE(bin.length,0); binHeader.writeUInt32LE(0x004e4942,4);
const model = modelFile ? await fs.readFile(modelFile) : Buffer.concat([header,padded,binHeader,bin]);
const panel = await fs.readFile('components/canvas/chat-panel/OpenClawModel3DPreview.tsx', 'utf8');
const effectStart = panel.lastIndexOf(' useEffect(() => {', panel.indexOf('if (!isActive || manifest !== undefined) return;'));
const effectEnd = panel.indexOf(' }, [interactionManifestIndex, chatId, isActive, manifest, messageId]);', effectStart);
const manifestEffect = panel.slice(effectStart, effectEnd + ' }, [interactionManifestIndex, chatId, isActive, manifest, messageId]);'.length);
const entry = `import React,{useEffect,useState} from 'react';import {createRoot} from 'react-dom/client';import {OpenClawInteractiveModelViewer} from './components/canvas/chat-panel/OpenClawInteractiveModelViewer';import {Model3DViewer} from './components/canvas/model3d/Model3DViewer';
const buildOpenClawArtifactProxyUrl=()=>'/pending-manifest';function ManifestProbe(){const [manifest,setManifest]=useState();const [manifestError,setManifestError]=useState(null);const interactionManifestIndex=1,chatId='test',messageId='test',isActive=true;const normalizeOpenClawModelInteractionManifest=x=>x;${manifestEffect};return <div role="status">{manifest===null?'基础预览': '等待清单'} {manifestError}</div>};const noop=()=>{};const manifest={parts:[{nodeName:'panel',partId:'panel',partName:'测试面板',explodedOffsetMm:{x:500,y:0,z:0}}],scaleMmToGlb:0.001,actions:[],drag:{axes:['x','y','z'],maxDistanceMm:2000,snapDistanceMm:10}};const params=new URLSearchParams(location.search);const url='/model.glb?case='+params.get('case');
createRoot(document.getElementById('root')).render(params.get('viewer')==='manifest'?<ManifestProbe/>:params.get('viewer')==='basic'?<Model3DViewer autoFitModel localStudioEnvironment modelUrl={url} modelFormat="glb" settings={{cameraPreset:'iso',envPreset:'studio',lightIntensity:1,autoRotate:false,showGrid:false,wireframe:false}} onStatsChange={stats=>window.loaded=stats.meshCount>0}/>:<OpenClawInteractiveModelViewer allowDragging={false} background="#09090b" manifest={manifest} modelUrl={url} onMatchChange={(matched,total)=>window.loaded=total>0} onSelectedPartChange={noop} resetAllToken={0} resetSelectedToken={0} resetViewToken={0} selectedNodeName={null} sourceVariant="assembled" transformRequest={{actionId:null,amount:0,animate:false,durationMs:0,nonce:0}}/>);`;
await esbuild.build({stdin:{contents:entry,resolveDir:root,loader:'tsx'},bundle:true,outfile:path.join(temp,'app.js'),jsx:'automatic',define:{'process.env.NODE_ENV':'"production"'},plugins: ref ? [{name:'old-viewers',setup(build){build.onLoad({filter:/(OpenClawInteractiveModelViewer|Model3DViewer)\.tsx$/},args=>({contents:execFileSync('git',['show',`${ref}:${path.relative(root,args.path)}`],{encoding:'utf8'}),loader:'tsx'}));}}] : []});
let requests = 0;
const server = http.createServer(async (req,res)=>{
const url=new URL(req.url,'http://localhost');
if(url.pathname==='/model.glb') {requests++;if(url.searchParams.get('case')==='slow') await new Promise(r=>setTimeout(r,1800));if(url.searchParams.get('case')==='error'){res.writeHead(500);res.end();return;}res.setHeader('Content-Type','model/gltf-binary');res.end(model);return;}
if(url.pathname==='/app.js'){res.setHeader('Content-Type','text/javascript');res.end(await fs.readFile(path.join(temp,'app.js')));return;}
res.setHeader('Content-Type','text/html');res.end('<style>body{margin:0;background:#09090b;color:white}#root{width:720px;height:500px}svg{width:24px;height:24px}</style><div id="root"></div><script src="/app.js"></script>');
});
await new Promise(r=>server.listen(0,'127.0.0.1',r));
const origin=`http://127.0.0.1:${server.address().port}`;
const browser=await chromium.launch({channel:'chrome',headless:true,args:['--enable-unsafe-swiftshader']});
try {
for(const viewer of ['interactive','basic']){
const page=await browser.newPage({viewport:{width:720,height:500}});const external=[];
// Simulate an overseas CDN connection that never completes, rather than instantly failing.
await page.route('**/*',route=>{if(route.request().url().startsWith(origin))return route.continue();external.push(new URL(route.request().url()).hostname);});
await page.goto(`${origin}/?viewer=${viewer}&case=slow`);
if(!ref) await page.getByRole('status').waitFor({timeout:10000});
await page.waitForTimeout(5000);
const screenshot=await page.locator('canvas').screenshot();
const {data,info}=await sharp(screenshot).removeAlpha().raw().toBuffer({resolveWithObject:true});
let visiblePixels=0;for(let i=0;i<data.length;i+=info.channels)if(Math.max(data[i],data[i+1],data[i+2])>80)visiblePixels++;
const loaded=await page.evaluate(()=>!!window.loaded);
await fs.writeFile(path.join(temp,`${viewer}.png`),screenshot);
console.log(JSON.stringify({viewer,loaded,visiblePixels,external,requests,screenshot:path.join(temp,`${viewer}.png`)}));
if(ref){assert(external.includes('raw.githack.com'));assert(!loaded,'old viewer stays suspended while environment request hangs');assert(visiblePixels<2000,'old viewer renders no model');}
else {assert(loaded);assert.equal(external.length,0,'Claw viewer must not need an external environment');assert(visiblePixels>5000,'the model must be visible, not just loaded');assert.equal(await page.getByRole('status').count(),0);}
await page.close();
}
if(!ref){
const probe=await browser.newPage();await probe.clock.install();
await probe.route('**/pending-manifest',()=>{});
await probe.goto(`${origin}/?viewer=manifest`);await probe.getByText('等待清单').waitFor();
await probe.clock.fastForward(16000);await probe.getByText('基础预览 交互清单加载超时').waitFor();
await probe.close();console.log('Manifest timeout switches to basic viewer after 15 seconds');
const page=await browser.newPage();await page.goto(`${origin}/?viewer=interactive&case=error`);await page.getByText('3D 交互预览加载失败', {exact:false}).waitFor();await page.close();console.log('HTTP failure displays an explicit fallback');}
} finally {await browser.close();server.close();}