Files
Genarrative/packages/model3d-viewer/src/scene.ts
T
k88936 3e426a1006 容器首帧无尺寸时按真实比例重算取景
- 取景原先只在加载完成时按 measureViewport() 的宽高比算一次,隐藏面板里挂载时只能拿到方形兜底值
- 抽出 computeFraming(aspect),ResizeObserver 首次量到非零尺寸后按真实比例重算并复位一次,之后不再覆盖用户的缩放
2026-09-21 15:17:11 +08:00

387 lines
11 KiB
TypeScript

import type * as ThreeTypes from 'three';
import {
computeModel3dViewerBoxCenter,
computeModel3dViewerBoxRadius,
computeModel3dViewerFraming,
createModel3dViewerFitSolver,
MODEL3D_VIEWER_CAMERA_FOV_DEGREES,
} from './framing';
import {
describeModel3dViewerError,
loadModel3dViewerObject,
Model3dViewerAbortedError,
Model3dViewerLoadError,
type Model3DViewerSource,
} from './loader';
const MAX_PIXEL_RATIO = 2;
/** 与共享组件同口径的半球光 / 主光,保证 PBR 材质可见;场景背景交给宿主。 */
const HEMISPHERE_LIGHT_SKY = 0xffffff;
const HEMISPHERE_LIGHT_GROUND = 0x445566;
const HEMISPHERE_LIGHT_INTENSITY = 2.2;
const KEY_LIGHT_INTENSITY = 2.0;
const ENVIRONMENT_INTENSITY = 0.9;
const TONE_MAPPING_EXPOSURE = 1.1;
const DAMPING_FACTOR = 0.08;
const AUTO_ROTATE_SPEED = 1.6;
/** 单个几何最多采样多少顶点,避免超大模型在打开瞬间卡住主线程。 */
const MAX_FIT_SAMPLES_PER_GEOMETRY = 200_000;
export type Model3dViewerScene = {
resetView(): void;
setAutoRotate(enabled: boolean): void;
dispose(): void;
};
export type Model3dViewerSceneInput = {
canvas: HTMLCanvasElement;
container: HTMLElement;
source: Model3DViewerSource;
autoRotate: boolean;
isAborted: () => boolean;
};
/** 释放模型与场景里所有几何、材质、贴图,避免反复开关时显存只涨不降。 */
export function disposeModel3dViewerObject(object: ThreeTypes.Object3D) {
object.traverse((child) => {
const mesh = child as ThreeTypes.Mesh;
mesh.geometry?.dispose?.();
const material = mesh.material;
const materials = Array.isArray(material)
? material
: material
? [material]
: [];
for (const entry of materials) {
for (const value of Object.values(
entry as unknown as Record<string, unknown>,
)) {
const texture = value as
| { isTexture?: boolean; dispose?: () => void }
| undefined;
if (texture?.isTexture && typeof texture.dispose === 'function') {
texture.dispose();
}
}
(entry as { dispose?: () => void }).dispose?.();
}
});
}
/** 把模型真实顶点按世界坐标喂给取景求解器,避免包围球/包围盒空角带来的过度取景。 */
function feedModel3dViewerFitSolver(
object: ThreeTypes.Object3D,
add: (x: number, y: number, z: number) => void,
) {
const readElement = (source: ArrayLike<number>, index: number) =>
source[index] ?? 0;
object.updateMatrixWorld(true);
object.traverse((child) => {
const mesh = child as ThreeTypes.Mesh;
const position = mesh.geometry?.attributes?.position;
if (!position || position.count === 0) {
return;
}
const elements = mesh.matrixWorld.elements;
const m0 = readElement(elements, 0);
const m1 = readElement(elements, 1);
const m2 = readElement(elements, 2);
const m4 = readElement(elements, 4);
const m5 = readElement(elements, 5);
const m6 = readElement(elements, 6);
const m8 = readElement(elements, 8);
const m9 = readElement(elements, 9);
const m10 = readElement(elements, 10);
const m12 = readElement(elements, 12);
const m13 = readElement(elements, 13);
const m14 = readElement(elements, 14);
const push = (index: number) => {
const x = position.getX(index);
const y = position.getY(index);
const z = position.getZ(index);
add(
m0 * x + m4 * y + m8 * z + m12,
m1 * x + m5 * y + m9 * z + m13,
m2 * x + m6 * y + m10 * z + m14,
);
};
const stride = Math.max(
1,
Math.ceil(position.count / MAX_FIT_SAMPLES_PER_GEOMETRY),
);
for (let index = 0; index < position.count; index += stride) {
push(index);
}
if (stride > 1) {
// 抽样可能漏掉端点的极值,补最后一个顶点。
push(position.count - 1);
}
});
}
/**
* 创建 WebGL2 交互场景:加载模型、归一化取景、轨道操作、按需释放。
* 未挂载即完成加载时(React StrictMode 双调用)整体丢弃并释放,不留下第二个上下文。
*/
export async function createModel3dViewerScene(
input: Model3dViewerSceneInput,
): Promise<Model3dViewerScene> {
const { canvas, container, source, isAborted } = input;
const [THREE, orbitControlsModule] = await Promise.all([
import('three'),
import('three/examples/jsm/controls/OrbitControls.js'),
]);
let renderer: ThreeTypes.WebGLRenderer | null = null;
let scene: ThreeTypes.Scene | null = null;
let model: ThreeTypes.Object3D | null = null;
let environmentTexture: ThreeTypes.Texture | null = null;
let pmremGenerator: ThreeTypes.PMREMGenerator | null = null;
let roomEnvironment: ThreeTypes.Scene | null = null;
let controls: { dispose: () => void } | null = null;
let resizeObserver: ResizeObserver | null = null;
let frameHandle = 0;
let disposed = false;
function teardown() {
if (disposed) {
return;
}
disposed = true;
if (frameHandle !== 0) {
window.cancelAnimationFrame(frameHandle);
frameHandle = 0;
}
resizeObserver?.disconnect();
resizeObserver = null;
controls?.dispose();
controls = null;
if (model) {
disposeModel3dViewerObject(model);
model = null;
}
environmentTexture?.dispose();
environmentTexture = null;
pmremGenerator?.dispose();
pmremGenerator = null;
if (roomEnvironment) {
disposeModel3dViewerObject(roomEnvironment);
roomEnvironment = null;
}
if (renderer) {
renderer.dispose();
try {
renderer.forceContextLoss();
} catch {
// 上下文已经丢失时忽略,释放流程继续。
}
renderer = null;
}
}
function abortIfNeeded() {
if (!isAborted()) {
return;
}
teardown();
throw new Model3dViewerAbortedError();
}
try {
renderer = new THREE.WebGLRenderer({
canvas,
antialias: true,
alpha: true,
});
} catch (error) {
throw new Model3dViewerLoadError(
'webgl-unavailable',
`WebGL2 上下文不可用:${describeModel3dViewerError(error)}`,
);
}
scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(
MODEL3D_VIEWER_CAMERA_FOV_DEGREES,
1,
0.01,
1000,
);
renderer.setPixelRatio(
Math.min(window.devicePixelRatio || 1, MAX_PIXEL_RATIO),
);
renderer.toneMapping = THREE.NeutralToneMapping;
renderer.toneMappingExposure = TONE_MAPPING_EXPOSURE;
const { OrbitControls } = orbitControlsModule;
const orbitControls = new OrbitControls(camera, canvas);
controls = orbitControls;
orbitControls.enableDamping = true;
orbitControls.dampingFactor = DAMPING_FACTOR;
orbitControls.screenSpacePanning = true;
orbitControls.autoRotate = input.autoRotate;
orbitControls.autoRotateSpeed = AUTO_ROTATE_SPEED;
scene.add(
new THREE.HemisphereLight(
HEMISPHERE_LIGHT_SKY,
HEMISPHERE_LIGHT_GROUND,
HEMISPHERE_LIGHT_INTENSITY,
),
);
const keyLight = new THREE.DirectionalLight(0xffffff, KEY_LIGHT_INTENSITY);
keyLight.position.set(2, 3, 4);
scene.add(keyLight);
try {
const { RoomEnvironment } = await import(
'three/examples/jsm/environments/RoomEnvironment.js'
);
roomEnvironment = new RoomEnvironment();
pmremGenerator = new THREE.PMREMGenerator(renderer);
environmentTexture = pmremGenerator.fromScene(
roomEnvironment,
0.04,
).texture;
scene.environment = environmentTexture;
scene.environmentIntensity = ENVIRONMENT_INTENSITY;
} catch {
// 环境贴图只影响材质观感,取不到时退化为纯光照渲染。
if (roomEnvironment) {
disposeModel3dViewerObject(roomEnvironment);
roomEnvironment = null;
}
pmremGenerator?.dispose();
pmremGenerator = null;
}
abortIfNeeded();
const model3d = await loadModel3dViewerObject(source, isAborted).catch(
(error: unknown) => {
teardown();
throw error;
},
);
model = model3d;
scene.add(model3d);
abortIfNeeded();
const bounds = new THREE.Box3().setFromObject(model3d);
const viewport = measureViewport();
/**
* 按给定宽高比重算取景。容器在隐藏面板里挂载时首帧量不到尺寸,只能先按方形兜底,
* 等容器真正有尺寸后必须用真实比例重算,否则模型会按方形取景、被裁掉或显得过小。
*/
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(),
});
}
let framing = computeFraming(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;
}
renderer.setSize(current.width, current.height, false);
camera.aspect = current.aspect;
camera.updateProjectionMatrix();
}
function resetView() {
camera.near = framing.near;
camera.far = framing.far;
camera.updateProjectionMatrix();
orbitControls.minDistance = framing.minDistance;
orbitControls.maxDistance = framing.maxDistance;
orbitControls.target.set(
framing.center.x,
framing.center.y,
framing.center.z,
);
camera.position.set(
framing.center.x + framing.resetDirection.x * framing.distance,
framing.center.y + framing.resetDirection.y * framing.distance,
framing.center.z + framing.resetDirection.z * framing.distance,
);
orbitControls.update();
}
resetView();
applyViewport();
if (typeof ResizeObserver !== 'undefined') {
resizeObserver = new ResizeObserver(() => {
applyViewport();
if (disposed || hasFramedViewport) {
return;
}
// 首次拿到非零尺寸:按真实比例重算取景并复位,用户此后的缩放不再被覆盖。
const current = measureViewport();
if (current.width === 0 || current.height === 0) {
return;
}
hasFramedViewport = true;
framing = computeFraming(current.aspect);
resetView();
});
resizeObserver.observe(container);
}
function renderFrame() {
if (disposed || !renderer || !scene) {
return;
}
frameHandle = window.requestAnimationFrame(renderFrame);
orbitControls.update();
renderer.render(scene, camera);
}
frameHandle = window.requestAnimationFrame(renderFrame);
return {
resetView,
setAutoRotate(enabled: boolean) {
orbitControls.autoRotate = enabled;
},
dispose: teardown,
};
}