-
3D 预览加载失败,已切换为静态预览
+
{previewImageUrl ? "3D 预览加载失败,可查看静态预览或下载模型" : "3D 预览加载失败,请重试或下载模型"}
) : null}
+ {localStudioEnvironment ?
: null}
:
@@ -880,8 +886,12 @@ export function Model3DViewer({
scale={10}
/>
-
+ {!localStudioEnvironment ? (
+
+
+
+ ) : null}
{
+ const timer = setTimeout(() => setSlow(true), 15000);
+ return () => clearTimeout(timer);
+ }, []);
+ return (
+
+
+
+
{slow ? "模型加载较慢,可退出预览后重试,或下载模型查看" : "正在加载 3D 模型…"}
+
+
+ );
+}
diff --git a/docs/incidents/claw-3d-black-screen-2026-09-15.md b/docs/incidents/claw-3d-black-screen-2026-09-15.md
new file mode 100644
index 000000000..6a16d0a45
--- /dev/null
+++ b/docs/incidents/claw-3d-black-screen-2026-09-15.md
@@ -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=` 可检查本地真实模型,不上传文件。
+
+15 项 `tests/routes/openclaw-3d-artifacts.test.ts` 测试通过;TypeScript 检查通过。浏览器测试未调用生产生成功能、未修改业务数据,真实模型文件不入库。
diff --git a/scripts/verify-claw-3d-loading.mjs b/scripts/verify-claw-3d-loading.mjs
new file mode 100644
index 000000000..146e34fac
--- /dev/null
+++ b/scripts/verify-claw-3d-loading.mjs
@@ -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=] [--model-file=]
+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 {manifest===null?'基础预览': '等待清单'} {manifestError}
};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'?:params.get('viewer')==='basic'?window.loaded=stats.meshCount>0}/>: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('');
+});
+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;i80)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();}