fix: render Claw models without waiting for external HDR lighting
This commit is contained in:
parent
54c857d714
commit
3df44f1f8c
@ -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}
|
||||
|
||||
@ -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}
|
||||
|
||||
31
components/canvas/model3d/LocalStudioEnvironment.tsx
Normal file
31
components/canvas/model3d/LocalStudioEnvironment.tsx
Normal 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;
|
||||
}
|
||||
@ -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}
|
||||
|
||||
21
components/canvas/model3d/ModelLoadingFallback.tsx
Normal file
21
components/canvas/model3d/ModelLoadingFallback.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
24
docs/incidents/claw-3d-black-screen-2026-09-15.md
Normal file
24
docs/incidents/claw-3d-black-screen-2026-09-15.md
Normal 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 检查通过。浏览器测试未调用生产生成功能、未修改业务数据,真实模型文件不入库。
|
||||
73
scripts/verify-claw-3d-loading.mjs
Normal file
73
scripts/verify-claw-3d-loading.mjs
Normal 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();}
|
||||
Loading…
x
Reference in New Issue
Block a user