响应式设计
本教程共 40 篇 · 第 8 篇 · 更新于 2026-08-14 · 约 7 分钟阅读
本节目标:让 three.js 画面在窗口缩放、设备旋转、高清屏上不变形、不模糊,学会 resize 事件、像素比适配和抗锯齿。
窗口变了,画面为什么变形
canvas 的尺寸是创建时固定的。窗口变宽后,canvas 没跟着变,画面要么被拉伸,要么留白,要么被裁掉。
要让画面自适应,需要同时更新三样东西:
- canvas 的显示尺寸,由渲染器负责。
- 相机的宽高比
aspect。 - 相机的投影矩阵。
缺了任何一步,画面都会不正常。下面逐个说。
先解释一个常见的疑问:为什么不能只用 CSS 把 canvas 拉大?因为 canvas 的绘图缓冲区是在 setSize 时按像素分配的,CSS 拉伸只是把已画好的图放大或缩小,结果要么模糊,要么变形。正确的做法是让缓冲区、显示尺寸、相机比例三者保持一致。
更新相机:aspect 与投影矩阵
宽高比变了,透视相机的画面比例就得跟着变:
camera.aspect = container.clientWidth / container.clientHeight;
camera.updateProjectionMatrix();
updateProjectionMatrix() 用新的 aspect 重新计算投影矩阵。改了 aspect 不调用它,是最常见的坑:相机数据是新的,投影矩阵还是旧的,画面比例一直不对。
更新渲染器:setSize
renderer.setSize(container.clientWidth, container.clientHeight);
setSize 同时设置 canvas 的样式尺寸和内部缓冲区尺寸。样式尺寸决定显示多大,缓冲区尺寸决定画多清晰。
它还有一个可选参数 updateStyle,默认 true。如果 canvas 的尺寸完全由 CSS 控制,可以传 false,让 setSize 只管缓冲区:
renderer.setSize(w, h, false); // 不碰 CSS 样式
也就是说 canvas 有两个尺寸概念:显示尺寸(CSS)和缓冲区分辨率(绘图表面)。setSize 设置两者,setPixelRatio 控制两者的倍数关系。显示器像素密度越高,缓冲区分辨率就要越高,画面才不糊。
监听 resize 事件
窗口尺寸变化时,window 会触发 resize 事件:
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
手机横竖屏切换、拖拽窗口、把窗口拖到另一块显示器,都会触发这个事件。一段代码全部覆盖。
拖拽窗口时 resize 会连续触发,但回调里只有两三行赋值,开销极小,一般不需要防抖。如果回调里塞了重活(比如重新加载纹理),再考虑加防抖。
更好的做法是把更新逻辑抽成函数,初始化和事件回调共用一份代码,保证两处逻辑一致:
function updateSize() {
camera.aspect = container.clientWidth / container.clientHeight;
camera.updateProjectionMatrix();
renderer.setSize(container.clientWidth, container.clientHeight);
}
updateSize(); // 初始化
window.addEventListener('resize', updateSize);
高清屏:像素比
window.devicePixelRatio 是设备像素比。Retina 屏是 2,意思是 1 个 CSS 像素对应 2×2 个物理像素。
不处理像素比,高清屏上的画面会发虚:canvas 只有 CSS 尺寸对应的物理像素,屏幕却要用更多像素去显示。
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
Math.min(dpr, 2) 把像素比封顶在 2。3 倍屏的清晰度收益肉眼难辨,但像素数量是 9 倍,渲染开销大很多,移动端尤其明显。
还有一个性能点:setSize 的尺寸越大,每帧要填充的像素越多。全屏 + 像素比 2,就是 4 倍像素量。场景复杂、帧率不够时,适当降低像素比是立竿见影的优化手段。
Note
setPixelRatio和setSize配合使用:像素比决定内部画多清晰,尺寸决定显示多大。只设尺寸不设像素比,高清屏上就是糊的。
抗锯齿:消灭锯齿边
斜线在像素网格上会呈现阶梯状,这叫锯齿(jaggies)。渲染器默认不开抗锯齿,物体边缘会比较毛糙。
const renderer = new THREE.WebGLRenderer({ antialias: true });
antialias 必须在创建渲染器时设置,创建之后改不了。它走 GPU 的多重采样(MSAA),现代浏览器基本都支持,对性能影响不大。
Note想改
antialias,只能重建一个渲染器。创建前想清楚,大部分场景直接开 true 就行。
移动端性能紧张时,可以考虑关掉抗锯齿换帧率——锯齿通常比掉帧容易接受。
完整示例
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>8. 响应式设计</title>
<style>
* { margin: 0; padding: 0; }
html, body { height: 100%; overflow: hidden; }
canvas { display: block; }
</style>
</head>
<body>
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.185.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.185.0/examples/jsm/"
}
}
</script>
<script type="module">
import * as THREE from 'three';
// 用容器尺寸初始化,而不是写死数字
const container = document.body;
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x1a1a2e);
const camera = new THREE.PerspectiveCamera(
50,
container.clientWidth / container.clientHeight,
0.1, 100
);
camera.position.set(3, 3, 5);
camera.lookAt(0, 0, 0);
const cube = new THREE.Mesh(
new THREE.BoxGeometry(1.5, 1.5, 1.5),
new THREE.MeshBasicMaterial({ color: 0x63e6be })
);
scene.add(cube);
// 抗锯齿必须在创建时开启
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(container.clientWidth, container.clientHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
container.append(renderer.domElement);
// 窗口尺寸变化:更新相机宽高比 + 投影矩阵 + 渲染器尺寸
window.addEventListener('resize', () => {
camera.aspect = container.clientWidth / container.clientHeight;
camera.updateProjectionMatrix();
renderer.setSize(container.clientWidth, container.clientHeight);
});
// 动画循环
const clock = new THREE.Clock();
function animate() {
requestAnimationFrame(animate);
const delta = clock.getDelta();
cube.rotation.x += 0.6 * delta;
cube.rotation.y += 0.9 * delta;
renderer.render(scene, camera);
}
animate();
</script>
</body>
</html>
运行后拖拽窗口边缘,或者按 F12 打开开发者工具的设备模拟器旋转屏幕。方块始终居中,比例不变,边缘平滑。
别忘了 HTML 里的 <meta name="viewport">。没有它,手机浏览器会把页面按桌面宽度渲染再缩小,尺寸计算全乱套。
还有一个边缘情况:把窗口从普通屏拖到 Retina 屏,devicePixelRatio 会变化。严谨的做法是在 resize 回调里重新设置像素比:
window.addEventListener('resize', () => {
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
// 其他更新:aspect、updateProjectionMatrix、setSize
});
大多数场景不处理也没问题,但常接多显示器的机器值得加上。
Tip用
clientWidth / clientHeight而不是innerWidth / innerHeight,代码就能同时适配全屏和局部容器,换到 React、Vue 项目里也能直接用。
容器化:ResizeObserver
监听 window.resize 只能覆盖窗口变化。如果 three.js 画布只是页面的一部分(比如侧边栏、某个卡片),应该监听容器元素。浏览器原生的 ResizeObserver 就是干这个的:
const observer = new ResizeObserver(() => {
camera.aspect = container.clientWidth / container.clientHeight;
camera.updateProjectionMatrix();
renderer.setSize(container.clientWidth, container.clientHeight);
});
observer.observe(container); // 容器尺寸一变,回调就执行
它比 window.resize 精确:容器尺寸变化就触发,与窗口无关。布局稳定后,这是更推荐的方案。
最后列几个常见问题和排查顺序:
- 画面模糊:没设
setPixelRatio,或设了但尺寸没跟着变。 - 画面拉伸变形:
aspect没更新,或忘了updateProjectionMatrix()。 - 画面空白或尺寸为 0:容器布局还没完成就初始化了 three.js,等布局完成后再取
clientWidth。
响应式三件套(aspect、投影矩阵、setSize)记牢,这类问题几分钟就能定位。