07 距离绘图与比例修正:圆形和图片
学完本章能做什么
你将能用距离画圆,解释为什么圆在宽平面上会变成椭圆,并能按目标区域与原图的宽高比实现图片的 cover(填满并裁切)和 contain(完整显示并留白)。
先看现象
打开示例,拖动“平面宽高比”。“圆:未修正”会随平面变宽而横向拉长;“圆:修正比例”保持圆形。然后切换图片模式:直接拉伸会让示例图中的太阳变形;cover 保持形状,但裁掉图像边缘;contain 保持完整图像,但在目标区域留下空白。
本章的目标区域是正对相机的平面:PlaneGeometry(2, 2) 的 x 缩放等于界面中的宽高比。若把平面斜向相机,真实圆在透视投影中仍可能看起来像椭圆,那是三维投影的正常效果。这里解决的是二维绘图区域本身宽高不等造成的拉伸。
1. 从点到中心的距离画圆
一个点到中心的二维距离可以用 length:
vec2 delta = vUv - vec2( 0.5, 0.5 );
float distanceToCenter = length( delta );
如果 distanceToCenter < radius,点在圆内;等于半径时在边界上。第 4 章的 step 能做硬边,第 5 章的 smoothstep 能做软边:
float circle = 1.0 - smoothstep( radius - 0.005, radius + 0.005, distanceToCenter );
vec3 color = mix( backgroundColor, circleColor, circle );
smoothstep 在中心距离由小变大时从 0 变 1;前面的 1.0 - 让圆内为 1、外为 0。这里 0.005 是本例在 UV 比例下选的羽化半宽,不代表固定的像素数。
2. 为什么 UV 圆会成为椭圆
设绘图区域在屏幕上宽 W、高 H,宽高比 A = W / H。UV 横向变化 Δu 对应的物理距离是 W * Δu,纵向变化 Δv 对应的是 H * Δv。直接计算 length(vec2(Δu, Δv)),相当于假设 W = H;如果实际 W = 2H,相同的 UV 数值变化在横向占据两倍的屏幕距离,画出的“圆”便被拉成横向椭圆。
把距离统一用目标区域的高度作单位,横向要乘 A:
vec2 point = ( vUv - 0.5 ) * vec2( uTargetAspect, 1.0 );
float distanceToCenter = length( point );
例如目标区域是 2:1,从中心向右走 0.15 UV,屏幕距离相当于 0.30 个目标高度;从中心向上走 0.30 UV,也相当于 0.30 个目标高度。两点应落在同一个圆周上。此写法让半径始终以高度为基准;改变目标宽度时,圆的屏幕高度不变。
如果画满整个画布,uTargetAspect 应取实际绘图区域的宽高比,例如 resolution.x / resolution.y。本例平面正对相机,平面的显示宽高比等于它自身的宽高比,所以 JavaScript 把同一个值用于 mesh.scale.x 和 uTargetAspect:
mesh.scale.x = aspect;
material.uniforms.uTargetAspect.value = aspect;
这个比值与图片原始宽高比是两件事;也不要把整个浏览器窗口的比例直接当成某个局部平面的比例。
3. 图片有自己的宽高比
本章使用一张 1200 × 800 的3:2 示例图片。图片原始宽高比 B = imageWidth / imageHeight = 1.5。目标区域宽高比记为 A。当 A != B,把原图 UV 原样铺到目标区域会改变形状:图中原本的圆也会跟着变形。
保持形状需要让目标屏幕中的横纵比例与取自原图的比例一致。我们把目标 UV vUv 映射成图片 UV:
vec2 imageUv = ( vUv - 0.5 ) * scale + 0.5;
先减 0.5 是把目标中心移到原点;乘 scale 后再加回 0.5,使缩放围绕中心发生。scale 的 x、y 取值决定是裁切,还是留白。
4. cover:填满目标,允许裁切
vec2 coverScale = vec2(
min( A / B, 1.0 ),
min( B / A, 1.0 )
);
vec2 imageUv = ( vUv - 0.5 ) * coverScale + 0.5;
coverScale 的两个分量都不超过 1,意味着只采样原图的中间一部分。若目标比图片更宽(A > B),x 分量是 1,y 分量小于 1:图片填满横向,上下被裁。若目标更窄(A < B),x 分量小于 1:左右被裁。
例如 A = 2.0、B = 1.5,coverScale = (1.0, 0.75)。目标从上到下只采样图片 y 方向中间的 0.125~0.875,上下各裁掉 0.125。原图中心的太阳仍是圆。
5. contain:完整显示图片,允许留白
vec2 containScale = vec2(
max( A / B, 1.0 ),
max( B / A, 1.0 )
);
vec2 imageUv = ( vUv - 0.5 ) * containScale + 0.5;
这里至少一个分量大于 1,目标区域的一部分会映射到图片 UV 的 0.0~1.0 之外。只在两个分量均位于原图范围内时采样,其余位置填背景色:
bool inside = imageUv.x >= 0.0 && imageUv.x <= 1.0 &&
imageUv.y >= 0.0 && imageUv.y <= 1.0;
if ( inside ) {
color = texture2D( uImage, imageUv ).rgb;
}
若直接对范围外的 UV 采样,纹理的边界设置可能把边缘像素拉成一条色带,达不到“留白”效果。仍以 A = 2.0、B = 1.5 为例,containScale = (1.333…, 1.0)。图片只占目标宽度的 B / A = 0.75,左右各留下目标宽度的 0.125。
| 显示方式 | 形状是否变形 | 目标是否填满 | 图片是否完整 |
|---|---|---|---|
| 原 UV 直接拉伸 | 可能变形 | 是 | 是 |
cover |
不变形 | 是 | 可能裁切 |
contain |
不变形 | 可能留白 | 是 |
示例由 TextureLoader 读取图片,并在加载后用 texture.image.width / texture.image.height 更新 uImageAspect。它还把图片纹理标为 THREE.SRGBColorSpace;片元着色器在计算空间中采样,并通过 #include <colorspace_fragment> 转为渲染器的输出色彩空间。
常见问题
- **圆越拉越扁:**检查使用的是绘图区域的宽高比,并检查是否把 x 分量乘以该比值。
cover与contain的缩放因子写反:cover用min,采样原图的一部分;contain用max,允许映射到原图范围外并填背景。- 图片出现彩色拉伸边:
contain时没有判断imageUv是否仍在0.0~1.0。 - **从本地文件直接打开后图片不出现:**用仓库根目录的 HTTP 静态服务器运行 ES 模块与资源加载。
- **修正后斜放的三维平面仍看起来椭圆:**本章修正的是二维区域的宽高比;透视投影和表面朝向会另行改变屏幕形状。
本章小结
在正对相机的二维区域,先把 UV 转成按实际宽高比统一度量的坐标,距离绘图才不会被拉伸。图片还要比较目标比例 A 与原图比例 B:cover 裁切,contain 留白。下一章将复用修正后的坐标,在同一平面上绘制多个真正的圆并组合 RGB 颜色。