容器尺寸变化时只重算相机取景,不再重新采一遍顶点

- packages/model3d-viewer/framing:取景求解器把每个采样点投影后的「上下向 / 左右向 / 深度」存成样本,resolve 可以带上新的宽高比重算;顶点投影与宽高比无关,比例变化时只在这份样本上重取最大值,距离算法与取值口径不变(含非有限顶点作废整次取景的旧行为)
- packages/model3d-viewer/scene:顶点采样改成加载后只做一次,computeFraming 只调用 resolve(aspect);拖拽面板连续触发 ResizeObserver 时不再重复 updateMatrixWorld 与逐顶点读取(单网格上限 20 万点)
- framing.test:补两条用例——带新比例重算与全新求解器结果一致、非有限顶点回落到与尺度相关的默认距离
This commit is contained in:
2026-09-24 19:19:58 +08:00
parent 6679dd2ebf
commit 3900aa9157
3 changed files with 118 additions and 27 deletions
@@ -203,6 +203,48 @@ describe('createModel3dViewerFitSolver', () => {
expect(Number.isFinite(fit)).toBe(true);
expect(fit).toBeGreaterThan(0);
});
it('带上新比例可复用同一份顶点样本重算取景距离', () => {
const rod: Point[] = [
[-1, -1, -1],
[1, 1, 1],
];
const solver = createModel3dViewerFitSolver({
center: computeModel3dViewerBoxCenter(CUBE),
aspect: 2,
fallbackRadius: computeModel3dViewerBoxRadius(CUBE),
});
for (const [x, y, z] of rod) {
solver.add(x, y, z);
}
const wide = solver.resolve(2);
const narrow = solver.resolve(0.5);
expect(wide).toBeCloseTo(solveForPoints(CUBE, rod, 2), 6);
expect(narrow).toBeCloseTo(solveForPoints(CUBE, rod, 0.5), 6);
expect(narrow).toBeGreaterThan(wide);
// 复用样本:再问一次同一个比例,结果不变。
expect(solver.resolve(2)).toBe(wide);
});
it('顶点里出现非有限值时整次取景回落到与尺度相关的默认距离', () => {
const solver = createModel3dViewerFitSolver({
center: computeModel3dViewerBoxCenter(CUBE),
aspect: 1,
fallbackRadius: computeModel3dViewerBoxRadius(CUBE),
});
solver.add(Number.NaN, 0, 0);
solver.add(1, 1, 1);
// 没有可用顶点时的同一口径:退化取景距离只由包围盒尺度决定。
const withoutSamples = createModel3dViewerFitSolver({
center: computeModel3dViewerBoxCenter(CUBE),
aspect: 1,
fallbackRadius: computeModel3dViewerBoxRadius(CUBE),
}).resolve(1);
expect(solver.resolve(1)).toBeCloseTo(withoutSamples, 6);
});
});
describe('computeModel3dViewerFraming', () => {
+60 -14
View File
@@ -52,7 +52,12 @@ export type Model3dViewerFitSolverInput = {
export type Model3dViewerFitSolver = {
add(x: number, y: number, z: number): void;
resolve(): number;
/**
* 取景距离。`aspect` 省略时用创建时的比例;带上新比例就能在**不重新采顶点**的前提下重算 ——
* 顶点投影出来的三个量(上下向、左右向、深度)与容器宽高比无关,只有左右向的视野上限随比例
* 变化,所以容器变窄变宽只需要在这一份样本上重取一次最大值。
*/
resolve(aspect?: number): number;
};
export type Model3dViewerFraming = {
@@ -186,26 +191,67 @@ export function createModel3dViewerFitSolver(
const halfFovY = (fovDegrees * Math.PI) / 360;
const limitY = fitFraction * Math.tan(halfFovY);
const limitX = limitY * aspect;
let required = 0;
const fallbackDistance = fallbackRadius / (fitFraction * Math.tan(halfFovY));
// 采样点按「上下向投影绝对值 / 左右向投影绝对值 / 深度」三个浮点数存下来,比例变化时直接在
// 这份样本上重算,不必再走一遍 updateMatrixWorld + 逐顶点读取(单个网格上限 20 万点)。
let sampleCount = 0;
let samples = new Float32Array(3 * 4096);
// 非法顶点照旧要作废整次取景:旧实现里 NaN 会把累加出的最大值污染成 NaN,这里保持同一口径。
let hasInvalidSample = false;
function resolveForAspect(value: number): number {
if (hasInvalidSample) {
return fallbackDistance;
}
const limitX = limitY * normalizePositive(value, FALLBACK_ASPECT);
let required = 0;
for (let index = 0; index < sampleCount; index += 1) {
const base = index * 3;
const upAbs = samples[base] ?? 0;
const rightAbs = samples[base + 1] ?? 0;
const depth = samples[base + 2] ?? 0;
const byUp = upAbs / limitY - depth;
if (byUp > required) {
required = byUp;
}
const byRight = rightAbs / limitX - depth;
if (byRight > required) {
required = byRight;
}
}
if (!Number.isFinite(required) || required <= 0) {
return fallbackDistance;
}
return Math.max(required * FIT_SAFETY, fallbackRadius);
}
return {
add(x: number, y: number, z: number) {
if (sampleCount * 3 + 3 > samples.length) {
const grown = new Float32Array(samples.length * 2);
grown.set(samples);
samples = grown;
}
const corner = subtract({ x, y, z }, center);
const depth = dot(corner, forward);
required = Math.max(
required,
Math.abs(dot(corner, up)) / limitY - depth,
Math.abs(dot(corner, right)) / limitX - depth,
);
},
resolve() {
if (!Number.isFinite(required) || required <= 0) {
return fallbackDistance;
const upAbs = Math.abs(dot(corner, up));
const rightAbs = Math.abs(dot(corner, right));
if (
!Number.isFinite(upAbs) ||
!Number.isFinite(rightAbs) ||
!Number.isFinite(depth)
) {
hasInvalidSample = true;
}
return Math.max(required * FIT_SAFETY, fallbackRadius);
const base = sampleCount * 3;
samples[base] = upAbs;
samples[base + 1] = rightAbs;
samples[base + 2] = depth;
sampleCount += 1;
},
resolve(value?: number) {
return resolveForAspect(value ?? aspect);
},
};
}
+16 -13
View File
@@ -285,27 +285,30 @@ export async function createModel3dViewerScene(
const bounds = new THREE.Box3().setFromObject(model3d);
const viewport = measureViewport();
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: viewport.aspect,
fovDegrees: MODEL3D_VIEWER_CAMERA_FOV_DEGREES,
fallbackRadius: computeModel3dViewerBoxRadius(fitBox),
});
// 顶点采样只做一次:投影结果与容器宽高比无关,容器尺寸变化只是在同一份样本上重取最大值,
// 不再重复 updateMatrixWorld + 逐顶点读取(拖拽面板时 ResizeObserver 会连续回调)。
feedModel3dViewerFitSolver(model3d, (x, y, z) => fitSolver.add(x, y, z));
/**
* 按给定宽高比重算取景。容器在隐藏面板里挂载时首帧量不到尺寸,只能先按方形兜底,
* 等容器真正有尺寸后必须用真实比例重算,否则模型会按方形取景、被裁掉或显得过小。
*/
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(),
distance: fitSolver.resolve(aspect),
});
}
@@ -406,7 +409,7 @@ export async function createModel3dViewerScene(
}
// 首帧按创建时的比例(容器量不到尺寸时是方形兜底)套一次取景;真实比例由下面的
// ResizeObserver 首轮回调重算,避免在这里重复一次顶点采样。
// ResizeObserver 首轮回调重算,重算只走缓存样本,不再重复顶点采样。
applyFraming(framing, false);
applyViewport();