查看器取景装配失败时释放 renderer 与模型资源

- packages/model3d-viewer/src/scene.ts:模型加载成功后的取景 / ResizeObserver / 渲染循环装配抽成 setupLoadedScene,由外层 try/catch 兜住;任何异常先 teardown 再抛出,避免调用方拿不到 scene 引用时漏掉 WebGL 上下文与模型 GPU 资源
This commit is contained in:
2026-09-24 17:23:58 +08:00
parent 55ce6d7129
commit 0c0819249d
+160 -143
View File
@@ -277,162 +277,179 @@ export async function createModel3dViewerScene(
abortIfNeeded();
const bounds = new THREE.Box3().setFromObject(model3d);
const viewport = measureViewport();
/**
* 按给定宽高比重算取景。容器在隐藏面板里挂载时首帧量不到尺寸,只能先按方形兜底,
* 等容器真正有尺寸后必须用真实比例重算,否则模型会按方形取景、被裁掉或显得过小。
* 模型已加载,但取景 / ResizeObserver / 渲染循环还没装好:这一段里的任何异常都必须先
* 释放 renderer 与模型资源再抛出,否则调用方拿不到 scene 引用,WebGL 上下文与 GPU 资源就漏了。
*/
function computeFraming(aspect: number) {
const fitBox = {
min: { x: bounds.min.x, y: bounds.min.y, z: bounds.min.z },
max: { x: bounds.max.x, y: bounds.max.y, z: bounds.max.z },
};
const fitSolver = createModel3dViewerFitSolver({
center: computeModel3dViewerBoxCenter(fitBox),
aspect,
fovDegrees: MODEL3D_VIEWER_CAMERA_FOV_DEGREES,
fallbackRadius: computeModel3dViewerBoxRadius(fitBox),
});
feedModel3dViewerFitSolver(model3d, (x, y, z) => fitSolver.add(x, y, z));
return computeModel3dViewerFraming({
box: fitBox,
aspect,
fovDegrees: MODEL3D_VIEWER_CAMERA_FOV_DEGREES,
distance: fitSolver.resolve(),
});
}
function setupLoadedScene(): Model3dViewerScene {
const bounds = new THREE.Box3().setFromObject(model3d);
const viewport = measureViewport();
let framing = computeFraming(viewport.aspect);
let framedAspect = viewport.aspect;
let hasFramedViewport = viewport.width > 0 && viewport.height > 0;
function measureViewport(): {
width: number;
height: number;
aspect: number;
} {
const rect = container.getBoundingClientRect();
const width = Math.max(Math.round(rect.width), 0);
const height = Math.max(Math.round(rect.height), 0);
return {
width,
height,
aspect: width > 0 && height > 0 ? width / height : 1,
};
}
function applyViewport() {
const current = measureViewport();
if (current.width === 0 || current.height === 0 || !renderer) {
return;
/**
* 按给定宽高比重算取景。容器在隐藏面板里挂载时首帧量不到尺寸,只能先按方形兜底,
* 等容器真正有尺寸后必须用真实比例重算,否则模型会按方形取景、被裁掉或显得过小。
*/
function computeFraming(aspect: number) {
const fitBox = {
min: { x: bounds.min.x, y: bounds.min.y, z: bounds.min.z },
max: { x: bounds.max.x, y: bounds.max.y, z: bounds.max.z },
};
const fitSolver = createModel3dViewerFitSolver({
center: computeModel3dViewerBoxCenter(fitBox),
aspect,
fovDegrees: MODEL3D_VIEWER_CAMERA_FOV_DEGREES,
fallbackRadius: computeModel3dViewerBoxRadius(fitBox),
});
feedModel3dViewerFitSolver(model3d, (x, y, z) => fitSolver.add(x, y, z));
return computeModel3dViewerFraming({
box: fitBox,
aspect,
fovDegrees: MODEL3D_VIEWER_CAMERA_FOV_DEGREES,
distance: fitSolver.resolve(),
});
}
renderer.setSize(current.width, current.height, false);
camera.aspect = current.aspect;
camera.updateProjectionMatrix();
}
/**
* 把取景套到相机与轨道控制器上。`preserveCamera` 时保留用户当前的观察方向与相对
* 缩放,只把相机沿原方向搬到新取景距离上;否则回到取景复位视角。
*/
function applyFraming(next: Model3dViewerFraming, preserveCamera: boolean) {
const position = preserveCamera
? resolveModel3dViewerReframedCameraPosition({
previous: framing,
next,
cameraPosition: {
x: camera.position.x,
y: camera.position.y,
z: camera.position.z,
},
})
: {
x: next.center.x + next.resetDirection.x * next.distance,
y: next.center.y + next.resetDirection.y * next.distance,
z: next.center.z + next.resetDirection.z * next.distance,
};
camera.near = next.near;
camera.far = next.far;
camera.updateProjectionMatrix();
orbitControls.minDistance = next.minDistance;
orbitControls.maxDistance = next.maxDistance;
orbitControls.target.set(next.center.x, next.center.y, next.center.z);
camera.position.set(position.x, position.y, position.z);
orbitControls.update();
framing = next;
}
let framing = computeFraming(viewport.aspect);
let framedAspect = viewport.aspect;
let hasFramedViewport = viewport.width > 0 && viewport.height > 0;
/**
* 宽高比确实变化时按新比例重算取景并套用:比例没变(同比例缩放容器)就保留现有取景,
* 不打断用户当前视角。返回是否重算了取景。
*/
function reframeIfAspectChanged(aspect: number, preserveCamera: boolean) {
if (
hasFramedViewport &&
Math.abs(aspect - framedAspect) <= framedAspect * FRAMING_ASPECT_TOLERANCE
) {
return false;
function measureViewport(): {
width: number;
height: number;
aspect: number;
} {
const rect = container.getBoundingClientRect();
const width = Math.max(Math.round(rect.width), 0);
const height = Math.max(Math.round(rect.height), 0);
return {
width,
height,
aspect: width > 0 && height > 0 ? width / height : 1,
};
}
applyFraming(computeFraming(aspect), preserveCamera);
framedAspect = aspect;
hasFramedViewport = true;
return true;
}
/**
* 复位视角:按容器当前宽高比重算取景,不复用可能过期的 `framing` —— 容器变窄后旧取景的
* 距离与缩放上下限都不再贴合,跟着旧取景复位会把模型裁掉或放得过小。
*/
function resetView() {
const current = measureViewport();
const measurable = current.width > 0 && current.height > 0;
// 容器量不到尺寸(隐藏面板、首帧)时只能按现有取景复位,等量到尺寸再重算。
applyFraming(measurable ? computeFraming(current.aspect) : framing, false);
if (measurable) {
framedAspect = current.aspect;
hasFramedViewport = true;
}
}
// 首帧按创建时的比例(容器量不到尺寸时是方形兜底)套一次取景;真实比例由下面的
// ResizeObserver 首轮回调重算,避免在这里重复一次顶点采样。
applyFraming(framing, false);
applyViewport();
if (typeof ResizeObserver !== 'undefined') {
resizeObserver = new ResizeObserver(() => {
applyViewport();
if (disposed) {
return;
}
function applyViewport() {
const current = measureViewport();
if (current.width === 0 || current.height === 0) {
if (current.width === 0 || current.height === 0 || !renderer) {
return;
}
// 首次拿到非零尺寸:按真实比例重算取景并复位,用户此后的缩放不再被覆盖。
// 此后只有宽高比确实变了(可缩放的面板)才重算,且保留用户当前的朝向与相对缩放。
reframeIfAspectChanged(current.aspect, hasFramedViewport);
});
resizeObserver.observe(container);
}
renderer.setSize(current.width, current.height, false);
camera.aspect = current.aspect;
camera.updateProjectionMatrix();
}
function renderFrame() {
if (disposed || !renderer || !scene) {
return;
/**
* 把取景套到相机与轨道控制器上。`preserveCamera` 时保留用户当前的观察方向与相对
* 缩放,只把相机沿原方向搬到新取景距离上;否则回到取景复位视角。
*/
function applyFraming(next: Model3dViewerFraming, preserveCamera: boolean) {
const position = preserveCamera
? resolveModel3dViewerReframedCameraPosition({
previous: framing,
next,
cameraPosition: {
x: camera.position.x,
y: camera.position.y,
z: camera.position.z,
},
})
: {
x: next.center.x + next.resetDirection.x * next.distance,
y: next.center.y + next.resetDirection.y * next.distance,
z: next.center.z + next.resetDirection.z * next.distance,
};
camera.near = next.near;
camera.far = next.far;
camera.updateProjectionMatrix();
orbitControls.minDistance = next.minDistance;
orbitControls.maxDistance = next.maxDistance;
orbitControls.target.set(next.center.x, next.center.y, next.center.z);
camera.position.set(position.x, position.y, position.z);
orbitControls.update();
framing = next;
}
/**
* 宽高比确实变化时按新比例重算取景并套用:比例没变(同比例缩放容器)就保留现有取景,
* 不打断用户当前视角。返回是否重算了取景。
*/
function reframeIfAspectChanged(aspect: number, preserveCamera: boolean) {
if (
hasFramedViewport &&
Math.abs(aspect - framedAspect) <=
framedAspect * FRAMING_ASPECT_TOLERANCE
) {
return false;
}
applyFraming(computeFraming(aspect), preserveCamera);
framedAspect = aspect;
hasFramedViewport = true;
return true;
}
/**
* 复位视角:按容器当前宽高比重算取景,不复用可能过期的 `framing` —— 容器变窄后旧取景的
* 距离与缩放上下限都不再贴合,跟着旧取景复位会把模型裁掉或放得过小。
*/
function resetView() {
const current = measureViewport();
const measurable = current.width > 0 && current.height > 0;
// 容器量不到尺寸(隐藏面板、首帧)时只能按现有取景复位,等量到尺寸再重算。
applyFraming(
measurable ? computeFraming(current.aspect) : framing,
false,
);
if (measurable) {
framedAspect = current.aspect;
hasFramedViewport = true;
}
}
// 首帧按创建时的比例(容器量不到尺寸时是方形兜底)套一次取景;真实比例由下面的
// ResizeObserver 首轮回调重算,避免在这里重复一次顶点采样。
applyFraming(framing, false);
applyViewport();
if (typeof ResizeObserver !== 'undefined') {
resizeObserver = new ResizeObserver(() => {
applyViewport();
if (disposed) {
return;
}
const current = measureViewport();
if (current.width === 0 || current.height === 0) {
return;
}
// 首次拿到非零尺寸:按真实比例重算取景并复位,用户此后的缩放不再被覆盖。
// 此后只有宽高比确实变了(可缩放的面板)才重算,且保留用户当前的朝向与相对缩放。
reframeIfAspectChanged(current.aspect, hasFramedViewport);
});
resizeObserver.observe(container);
}
function renderFrame() {
if (disposed || !renderer || !scene) {
return;
}
frameHandle = window.requestAnimationFrame(renderFrame);
orbitControls.update();
renderer.render(scene, camera);
}
frameHandle = window.requestAnimationFrame(renderFrame);
orbitControls.update();
renderer.render(scene, camera);
}
frameHandle = window.requestAnimationFrame(renderFrame);
return {
resetView,
setAutoRotate(enabled: boolean) {
orbitControls.autoRotate = enabled;
},
dispose: teardown,
};
return {
resetView,
setAutoRotate(enabled: boolean) {
orbitControls.autoRotate = enabled;
},
dispose: teardown,
};
}
try {
return setupLoadedScene();
} catch (error) {
teardown();
throw error;
}
}