3e426a1006
- 取景原先只在加载完成时按 measureViewport() 的宽高比算一次,隐藏面板里挂载时只能拿到方形兜底值 - 抽出 computeFraming(aspect),ResizeObserver 首次量到非零尺寸后按真实比例重算并复位一次,之后不再覆盖用户的缩放
387 lines
11 KiB
TypeScript
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,
|
|
};
|
|
}
|