首页
/ three.js GroundedSkybox 地面投影天空盒完全指南:工作原理、构造参数与 WebGL / WebGPU 两种集成方式

three.js GroundedSkybox 地面投影天空盒完全指南:工作原理、构造参数与 WebGL / WebGPU 两种集成方式

2026-09-07 11:02:54作者:何将鹤

本文基于 three.js 仓库中的 GroundedSkybox API 文档 展开。GroundedSkybox(地面投影天空盒)是一种专门的环境反射技巧:把 HDR 环境贴图"投影"到被地面圆盘裁切过的球形几何体上,让天空、地平线以及地面反射同时来自同一张环境贴图,适合汽车展厅、户外场景展示等需要真实反射又不想引入庞大场景模型的场合。读完本文你将掌握它的构造参数语义、源码级变形原理、场景摆放约定,以及在 WebGL(Mesh 对象)与 WebGPU(TSL 节点 getGroundProjectedNormal)两套渲染体系中的落地用法。

three.js 官方示例 webgl_materials_envmaps_groundprojected 的运行截图,一辆法拉利模型放置在由 GroundedSkybox 提供的水平地面上,车身反射出 Blouberg 日出 HDR 环境图

GroundedSkybox 是什么:解决"地面环境反射"问题

在基于图像的光照(IBL)工作流中,开发者通常会加载一张全景 HDR(equirectangular)环境贴图,并把它赋给 scene.environment。此时物体的天空部分反射是正确的,但水平地面与朝下朝向的反射会出问题:一张球面全景图的"下半球"并不代表一块平整的水平地面,直接拿去采样会得到变形、甚至来自头顶天空的错误颜色。

GroundedSkybox 给出的答案是:构造一个足够大的球体网格作为"天空",并对网格下半部分进行特殊变形,使下方变成一个平滑过渡的"地面圆盘"。渲染时这块"地面"从环境贴图对应的方位采样,于是场景中的水平地面、位于地面上的物体的朝下表面(如车漆反射、地面接触阴影区域),都能得到符合真实几何关系的环境反射。

从官方示例命名 ground projected environment mapping(见 examples/webgl_materials_envmaps_groundprojected.html 的标题)可以看出,它的本质是一种地面投影环境映射,而不是一个真实加载地面模型、铺设反射面等内容的场景构建方案——它只负责"把环境图正确地呈现在水平地面上"。

继承关系与使用前准备

原文档给出的继承链为:

EventDispatcher → Object3D → Mesh → GroundedSkybox

也就是说 GroundedSkybox 就是一个普通的 Mesh:它可以像任何 Mesh 一样被 scene.add()、设置 position / rotation / scale、参与矩阵更新与渲染剔除,也继承了 Object3D 的事件派发能力。

由于它属于扩展(addon)对象而非 three.js 核心库导出,必须显式导入:

import { GroundedSkybox } from 'three/addons/objects/GroundedSkybox.js';

在仓库中,该模块同时被汇总进 examples/jsm/Addons.js 的 addon 索引;官方示例运行时通过 import map 把 "three/addons/" 映射到 ./jsm/,因此可直接使用 three/addons/objects/GroundedSkybox.js 形式的导入路径。若你使用 npm 方式安装 addons,也可以选择从已发布的 three/addons 或本地 examples/jsm 目录按需引入。

构造函数与参数详解

new GroundedSkybox( map, height, radius, resolution = 128 )

参数 类型 默认值 说明
map Texture 必填 要使用的环境贴图。通常是一张 EquirectangularReflectionMapping 的 HDR 全景图(见下文示例)。
height number 必填 拍摄照片的相机距地面的高度。值越大,对图像向下部分的放大越明显。
radius number 必填 天空盒的半径,必须足够大,确保场景相机始终处于其内部。
resolution number 128 天空盒的几何细分分辨率,直接影响网格顶点密度。

参数合法性的源码校验

examples/jsm/objects/GroundedSkybox.js 的构造函数实现可以看到,三者都必须是正数,否则直接抛错:

if ( height <= 0 || radius <= 0 || resolution <= 0 ) {
    throw new Error( 'THREE.GroundedSkybox: height, radius, and resolution must be positive.' );
}

因此 heightradiusresolution 都不可传 0 或负数,否则对象无法创建。

场景摆放约定:把"地面"放到原点

原文档特别强调了一个使用要点:

By default the object is centered at the camera, so it is often helpful to set skybox.position.y = height to put the ground at the origin.

默认情况下天空盒以场景原点为中心,而"地面"应位于 y = 0(通常也是场景里车辆、模型所在的平面高度)。地面相对天空盒球心向下的距离正好由 height 决定,因此把对象整体抬高 height,就能让地面落到原点:

const height = 15, radius = 100;
const skybox = new GroundedSkybox( envMap, height, radius );
skybox.position.y = height;   // 把“地面”放到 y = 0
scene.add( skybox );

值得留意的是,官方 WebGL 示例 里使用的是 skybox.position.y = params.height - 0.01,即把地面略微放在 y = -0.01。这样可避免地面平面与放置在原点上的模型或额外绘制的阴影贴片发生"贴面闪烁/深度冲突"类问题,同时视觉上几乎不可察觉。你同样可以在此基础上微调。

源码剖析:几何体是如何构造与变形的

理解 GroundedSkybox 的关键在其构造函数中一段"纯 CPU 几何变形"逻辑(examples/jsm/objects/GroundedSkybox.js)。它并不依赖 shader,而是在创建时就完成所有顶点改写:

const geometry = new SphereGeometry( radius, 2 * resolution, resolution );
geometry.scale( 1, 1, - 1 );   // 翻转 Z,使球体内表面朝外(背面剔除后可见)

resolution 的作用在这里体现为球体分段数:widthSegments = 2 * resolutionheightSegments = resolution。默认 128 意味着约 256×128 的经纬分段,足以在普通距离下保持平滑轮廓;调低可降低顶点数与内存占用,调高可获得更圆滑的轮廓,但没必要过度增加(变形区域主要集中在底部)。

接下来遍历全部顶点,仅对朝下的半球y < 0)进行改写,把"球面底部"平滑摊平为一块近似的水平地面:

if ( tmp.y < 0 ) {
    // Smooth out the transition from flat floor to sphere:
    const y1 = - height * 3 / 2;
    const f = tmp.y < y1 ? - height / tmp.y : ( 1 - tmp.y * tmp.y / ( 3 * y1 * y1 ) );
    tmp.multiplyScalar( f );
    tmp.toArray( pos.array, 3 * i );
}

这段代码把球体下半部变形为一个"地面投影"曲面:

  • y1 = -height * 1.5 是过渡带阈值。当顶点足够低(y < y1,接近地面圆盘中心区域)时,缩放系数 f = -height / tmp.y,顶点会被压到接近 y = -height 的水平高度,形成平坦地面;
  • 处于过渡带的顶点则按二次曲线 f = 1 - y² / (3·y1²) 平滑缩放,使地面与上方的球面在视觉上无接缝地衔接;
  • 最后 pos.needsUpdate = true 通知 GPU 重新上传顶点缓冲。

上半球(天空)的顶点则不做任何处理,因此仍保留完整球面形状。最终网格使用极简的材质:

super( geometry, new MeshBasicMaterial( { map, depthWrite: false } ) );

这里两个细节值得注意:

  1. 使用 MeshBasicMaterial 而非受光照/受 tone mapping 影响的材质——天空盒本身不参与光照计算,颜色直接来自环境贴图采样;
  2. depthWrite: false 关闭深度写入,使天空盒不干扰场景中其他物体的深度排序(天空盒永远应当渲染在"最远层")。若在 WebGL 场景中把它与透明地面、阴影贴片混排,这一设置也避免了半透明物体的绘制顺序问题。

WebGPU / TSL 时代的另一种实现:getGroundProjectedNormal

随着 WebGPU 渲染器与 TSL(Three Shading Language)在仓库中的普及,同一套"地面投影环境映射"还提供了基于着色器求交的轻量实现 getGroundProjectedNormal,其独立 API 文档见 docs/pages/module-GroundedSkybox.html.md,源码在 examples/jsm/tsl/utils/GroundedSkybox.js

函数签名

import { getGroundProjectedNormal } from 'three/addons/tsl/utils/GroundedSkybox.js';
getGroundProjectedNormal( radiusNode: Node<float>, heightNode: Node<float> ) : Node<vec3>

它接受两个 TSL 数值节点,含义与网格版本的构造参数完全一致:

  • radiusNode:投影球半径,必须足够大以包住场景相机;
  • heightNode:拍摄相机离地高度,值越大对图像向下部分的放大越明显;

返回值是一个供采样环境立方体贴图的采样方向向量 vec3。与"CPU 改几何体"方案不同,它把"世界坐标 → 投影 → 采样方向"的计算全部放到 GPU 上,因此天空盒本体只需一个简单的 icosahedron(二十面体)即可。

从源码(examples/jsm/tsl/utils/GroundedSkybox.js)可以看到完整的数学流程:

  1. positionWorld 减去 cameraPosition 得到视线方向并归一化;
  2. 对"以相机为中心、半径为 radius"的球做射线-球求交sphereIntersect),得出第一次交点;
  3. 若命中球体,再对位于 (0, -height)、法线向上的地面圆盘做带背面剔除的射线-圆盘求交diskIntersectWithBackFaceCulling);
  4. 取两次交点中更近的那个 min( intersection, intersection2 ),沿该交点方向除以半径归一化,得到用于采样环境立方体贴图的 projected 向量。

在 WebGPURenderer 中的完整用法

官方 examples/webgpu_materials_envmaps_groundprojected.html 展示了全套接线方式。与 WebGL 示例的一大差异是:这里先把 equirectangular HDR 通过 CubeRenderTarget.fromEquirectangularTexture 转成立方体贴图——源码注释明确说明"use a cube map to avoid visual artifacts at the skybox's poles"(避免天空盒极点处的采样畸变):

const cubeRenderTarget = new THREE.CubeRenderTarget( envMap.image.height );
cubeRenderTarget.fromEquirectangularTexture( renderer, envMap );
const cubeMap = cubeRenderTarget.texture;

const geometry = new THREE.IcosahedronGeometry( 1, 16 );
const material = new THREE.MeshBasicNodeMaterial( { side: THREE.DoubleSide } );
material.colorNode = cubeTexture(
    cubeMap,
    getGroundProjectedNormal( float( params.radius ), float( params.height ) )
);

const skybox = new THREE.Mesh( geometry, material );
skybox.scale.setScalar( params.radius );   // 缩放几何体到所需半径
scene.add( skybox );

这里几何体使用 1 单位半径的 IcosahedronGeometry 然后整体 setScalar(radius) 放大,材料为 MeshBasicNodeMaterial,用 getGroundProjectedNormal 的输出驱动 cubeTexture() 的采样坐标;注意材质设置 side: THREE.DoubleSide 以兼容翻转朝向后的可见性。

端到端 WebGL 示例:从 HDR 加载到汽车展示

官方 WebGL 示例 examples/webgl_materials_envmaps_groundprojected.html 是一个可整体参考的范本,核心链路如下(截图见文首):

  1. 加载环境贴图:用 HDRLoader 加载仓库内的 examples/textures/equirectangular/blouberg_sunrise_2_1k.hdr,并设置 envMap.mapping = THREE.EquirectangularReflectionMapping
  2. 创建 GroundedSkyboxnew GroundedSkybox( envMap, params.height, params.radius ),其中参数由 lil-gui 面板驱动(height: 15, radius: 100),加入场景并将 skybox.position.y = params.height - 0.01
  3. 把同一张贴图用作场景 IBLscene.environment = envMap,这样车身上反射的不仅是天空盒,还包括来自环境图的漫反射/镜面反射光照;
  4. 放置反射明显的模型:示例加载 models/gltf/ferrari.glb 并应用 MeshPhysicalMaterial(金属度 1.0、clearcoat 1.0、玻璃材质 transmission: 1.0),让环境反射效果一目了然;
  5. 开启 ACES 色调映射renderer.toneMapping = THREE.ACESFilmicToneMapping,HDR 反射经 tone mapping 后视觉观感更真实;
  6. GUI 开关对比:通过 lil-gui 的 enabled 开关在"添加/移除 skybox"之间切换,移除时把 scene.background 恢复为 scene.environment,可直观对比有无地面投影天空盒的差异。

用 GUI 拖动 height 可以直观看到:地面越"深"(相机越高),向下反射的图像被放得越大;而 radius 必须远大于相机到场景中心的距离,否则相机一旦越出球体,周围会暴露出背景色/空洞。

调参实践与注意事项小结

  • height(地面深度):语义上是"拍摄该环境照片的相机离地面的高度"。它对地面区域反射的放大倍率起决定性作用:值越大,环境图下部被拉伸得越狠。对汽车展示类场景,通常从 5~20 之间起步并配合 GUI 微调;
  • radius(半径):只要保证相机及你关心的所有反射体都在球内即可。它并不影响地面与球的相对形态(地面高度只由 height 决定),但会影响顶点尺寸与远处轮廓。官方示例在限制相机 maxDistance = 80 的前提下使用 radius = 100,正是为了保证相机不出球;
  • resolution(几何分辨率):默认 128 已经足够平滑。增大它只会增加顶点数量,对变形区域(下半球)平滑度提升有限,慎用;
  • 朝向与深度:网格内部由 geometry.scale(1, 1, -1) 翻转后配合 MeshBasicMaterial 显示,depthWrite: false 保证不污染深度缓冲;不要在其上叠加会产生深度写入的地面网格到同一高度层,避免 z-fighting;
  • 极点畸变:如果发现天空/地面极点附近出现异常拉伸,可参照 WebGPU 示例把 equirectangular 图先经 CubeRenderTarget.fromEquirectangularTexture 转为 cube map 再采样;
  • 与场景 IBL 配合:记得同时设置 scene.environment,否则模型表面只能看到天空盒反射、得不到环境漫反射光照,画面会偏"死黑"。

相关资源

登录后查看全文
热门项目推荐
相关项目推荐