three.js 地面投影环境贴图 TSL 函数解析:getGroundProjectedNormal 使用与原理
本篇文章围绕 three.js 官方文档 module-GroundedSkybox 所讲解的 TSL(Three Shading Language)工具函数 getGroundProjectedNormal 展开,讲解"地面投影环境贴图(Ground Projected Environment Mapping)"的核心原理、API 参数含义与完整接入流程。读完你将掌握:如何在 three.js WebGPU 渲染管线中用一行 TSL 节点把普通球面环境贴图"压"到无限地面平面上,让 HDR 背景与真实物体在同一场景中无缝衔接,并理解其背后的球体-圆盘相交数学实现。
一、为什么需要"地面投影"环境贴图
在展示汽车、产品等室外场景时,开发者常用 HDR 全景图作为环境反射贴图(scene.environment)。但普通环境映射假设观察者位于贴图拍摄点,四周反射会呈现为封闭球面:无论相机视角如何压低,环境都没有"地平线以下被地面截断"的物理感,虚拟地面上的金属物体反射的是"天空的对面",视觉上像浮在空中。
getGroundProjectedNormal 解决的就是这个问题:它把世界坐标方向映射到一个"底部被地面圆盘裁剪的球面"上,即光线打到地面圆盘后即被视为从该命中点反射。这样环境贴图只在水平面以上呈现,以下部分与真实地面/放置平台自洽,营造出物体站在广袤地面上的真实效果。该项目提供的两个可运行参考页正是这一效果的直观演示:webgl_materials_envmaps_groundprojected.html 与 webgpu_materials_envmaps_groundprojected.html,其中后一个就是本文主角 TSL 版本的落地案例。
二、区分两个同名概念:TSL 工具函数与 GroundedSkybox 对象
查阅仓库 examples/jsm 目录可以发现,地面投影环境贴图存在两种互补的实现,二者共享同样的 height / radius 语义:
| 维度 | TSL 工具函数(本文主题) | GroundedSkybox 网格对象 |
|---|---|---|
| 源码位置 | examples/jsm/tsl/utils/GroundedSkybox.js | examples/jsm/objects/GroundedSkybox.js |
| 导出符号 | getGroundProjectedNormal |
GroundedSkybox(继承 Mesh) |
| 导入路径 | three/addons/tsl/utils/GroundedSkybox.js |
three/addons/objects/GroundedSkybox.js |
| 工作方式 | 返回一个 Node<vec3> 方向向量,喂给 TSL 的 cubeTexture 采样 |
构造一个变形的球体网格,配合环境贴图作天空盒网格使用 |
| 对应示例 | webgpu_materials_envmaps_groundprojected.html(WebGPU) | webgl_materials_envmaps_groundprojected.html(WebGL) |
两者都在官方文档中各有独立 API 页:本页 module-GroundedSkybox 讲解 TSL 函数,GroundedSkybox 讲解对象类。下文严格聚焦前者;在"八、常见问题"中会对两个方案的选型差异再做对比,避免混用。
三、导入方式:作为 Addon 显式引入
getGroundProjectedNormal 属于 three.js 的 addon 扩展,不在核心包内,必须显式导入:
import { getGroundProjectedNormal } from 'three/addons/tsl/utils/GroundedSkybox.js';
同时在引用 TSL 基础节点时配合从 three/tsl 导入配套符号。参考示例 webgpu_materials_envmaps_groundprojected.html 的完整引入写法:
import { getGroundProjectedNormal } from 'three/addons/tsl/utils/GroundedSkybox.js';
import { cubeTexture, float } from 'three/tsl';
使用 ES Module 场景下,需要项目具备指向 addon 目录的 import map(或打包器别名),例如把 three/addons/ 映射到 ./jsm/,这与 three.js 官方推荐的 Addons 安装方式一致。
四、API 详解:getGroundProjectedNormal( radiusNode, heightNode )
getGroundProjectedNormal( radiusNode : Node.<float>, heightNode : Node.<float> ) : Node.<vec3>
从源码看,该函数以 Fn( ( [ radiusNode, heightNode ] ) => { ... } ) 的形式定义于 examples/jsm/tsl/utils/GroundedSkybox.js,返回一个可嵌入 TSL 表达式树的 vec3 节点——即"用于采样环境立方体贴图的方向向量"。
radiusNode(投影球半径)
- 类型:
Node<float>。 - 含义:投影球(sphere)的半径,单位与场景世界坐标一致。
- 官方约束:必须足够大,确保场景中的相机始终位于球内。因为该方法以相机为基点做视线求交,若相机越出球面,视线与球不再有交点,投影会失效。
heightNode(拍摄高度)
- 类型:
Node<float>。 - 含义:拍摄这张环境照片时,相机距离地面的高度。直观表现上:
height越大,向下的画面部分被放大得越明显(下半球的拉伸越强)。 - 工程直觉:在 HDR 示例中它与你希望"地平线落在环境贴图竖直方向 1/2 处"的观感直接相关——
height决定了贴图几何上的假想拍摄点,也就决定了投影后地面附近反射的拉伸程度。
返回值
- 类型:
Node<vec3>。 - 语义:一个归一化方向向量,可直接交给
cubeTexture( map, dir )或envMap类节点进行方向采样。
需要特别强调:入参必须是"节点"而不是裸数值。示例中调用一律用 float( params.radius )、float( params.height ) 包一层,正是为了让普通数字变成 Node<float>(参见 webgpu_materials_envmaps_groundprojected.html)。
五、把函数接入材质:完整 WebGPU 实操流程
参考 webgpu_materials_envmaps_groundprojected.html,实际项目接入该函数共需三步。
第一步:把全景 HDR 转成立方体贴图
示例先加载 .hdr 等距柱状全景图作为 scene.environment(负责给车身提供 IBL 光照),随后单独将其转换成立方体贴图用于天空盒采样。源码注释点明原因:使用立方体贴图可避免天空盒在极点(poles)处产生视觉伪影。
envMap.mapping = THREE.EquirectangularReflectionMapping;
scene.environment = envMap;
// 转成立方体贴图,避免天空盒极点伪影
const size = envMap.image.height;
const cubeRenderTarget = new THREE.CubeRenderTarget( size );
cubeRenderTarget.fromEquirectangularTexture( renderer, envMap );
const cubeMap = cubeRenderTarget.texture;
第二步:用 IcosahedronGeometry 与 Node 材质构建天空盒
TSL 版本没有使用专用几何体,而是用一个细分度较高的二十面体(IcosahedronGeometry( 1, 16 )),再把法线采样逻辑放进 colorNode:
const params = { height: 15, radius: 100 };
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,再整体 scale.setScalar( radius ) 放大。由于投影方向只依赖视线方向而非顶点位置,缩放不会改变法线语义,却让网格始终包住场景。示例中 height = 15、radius = 100、相机位于 ( -20, 7, 20 ),恰好满足"相机处于球内"的约束。
第三步:动态开关
参考页还通过 GUI 控制 enabled 切换:开启时把 skybox 加入场景并清空 scene.background;关闭时移除 skybox、回退到 scene.environment,方便对比地面投影前后差异:
if ( value ) {
scene.add( skybox );
scene.background = null;
} else {
scene.remove( skybox );
scene.background = scene.environment;
}
六、源码级原理推演:一次求交如何完成"地面投影"
下面逐段解读 examples/jsm/tsl/utils/GroundedSkybox.js 的实现,它是理解 height / radius 两个参数含义的最直接证据。函数顶部从 three/tsl 引入 Fn、If、vec3、float、min、cameraPosition、positionWorld 等节点构造器与变量。
1. 准备工作:视线方向与相机下移
const p = positionWorld.sub( cameraPosition ).normalize().toConst();
const camPos = cameraPosition.toVar();
camPos.y.subAssign( heightNode );
p 是"片元世界坐标 − 相机位置"的归一化视线方向;随后把相机位置整体沿 y 轴下移 height,等价于把拍摄全景照片时的假想相机放在地面以下 height 处,这正是上一节所述"高度决定下半部拉伸程度"的数学来源。
2. 相机到投影球的射线求交
const b = camPos.dot( p ).toConst();
const c = camPos.dot( camPos ).sub( radiusNode.mul( radiusNode ) ).toConst();
const h = b.mul( b ).sub( c ).toConst();
const intersection = h.greaterThanEqual( 0 ).select( h.sqrt().sub( b ), - 1 );
这一段在求解"以 camPos 为起点、方向 p 的射线与以原点为中心、半径为 radius 的球面"的交点(注释 sphereIntersect),使用经典二次方程判别式:h < 0 表示不相交,返回 -1 标记;否则返回较近交点距离 √h − b。若与球无交(即视线朝天空上方),后面直接给出兜底方向。
3. 与地面圆盘求交并做背面剔除
If( intersection.greaterThan( 0 ), () => {
const diskCenter = vec3( 0, heightNode.negate(), 0 ).toConst();
const n = vec3( 0, 1, 0 ).toConst();
const d = p.dot( n ).toConst();
const intersection2 = float( 1e6 ).toVar();
If( d.lessThanEqual( 0 ), () => {
const o = camPos.sub( diskCenter ).toConst();
const t = n.dot( o ).negate().div( d ).toConst();
const q = o.add( p.mul( t ) ).toConst();
If( q.dot( q ).lessThan( radiusNode.mul( radiusNode ) ), () => {
intersection2.assign( t );
} );
} );
...
视线与球相交后,还要判断它是否先命中地面:地面被建模为一个中心在 (0, −height, 0)、法线朝上 (0, 1, 0)、半径为 radius 的水平圆盘(diskIntersectWithBackFaceCulling)。命中圆盘等价于相机上方的光线正朝下(d ≤ 0),此时计算与圆盘所在平面的交点参数 t,再校验交点 q 是否落在圆盘范围内(q·q < radius²)。满足才记录 intersection2。
4. 取近者,生成投影方向
projected.assign( camPos.add( p.mul( min( intersection, intersection2 ) ) ).div( radiusNode ) );
return projected;
最终在"球面交点"与"圆盘交点"之间取距离更近者(min),把命中点的世界坐标归一化后即得到采样方向。凡是被圆盘截住的视线,其反射方向都来源于地面某一点——这就是环境贴图"无限延伸成地面"的成因。
七、参数调优建议
- radius(球半径):至少覆盖相机到原点最远距离。取值过大不会改变投影效果,只会增大"球"的存在感上限;过小则相机一旦移出球体,环境投影即刻失效。示例
radius = 100配合maxDistance = 80的相机限制,正是为了避免越界(见 webgpu_materials_envmaps_groundprojected.html)。 - height(拍摄高度):影响水平线以下内容的拉伸倍率。想让"地面"反射中的景物更宏大、更压向地平线,可调大 height;想让下半球更接近原始全景视角,则调小。示例默认
15,在相机高度 7 的场景中表现自然。 - 天空盒网格:务必使用高细分球体/二十面体(示例
IcosahedronGeometry( 1, 16 ))并用DoubleSide,同时配合cubeTexture采样以避免极区伪影。
八、常见问题与对象版选型对比
Q1:用 WebGL 渲染器能否使用 getGroundProjectedNormal?
TSL 属节点/着色器层抽象,仓库中该工具函数的现成落地示例基于 WebGPURenderer(webgpu_materials_envmaps_groundprojected.html)。若项目运行在 WebGL 管线,更省事的是使用对应的 GroundedSkybox 网格对象版本,它直接接受 Texture 并自动生成"下半球压扁"的天空盒几何体,用法见 webgl_materials_envmaps_groundprojected.html。
Q2:对象版 GroundedSkybox 的构造参数与本文有什么异同?
对象版构造函数为 new GroundedSkybox( map : Texture, height : number, radius : number, resolution : number = 128 ),height、radius 语义与 TSL 版完全一致;resolution 控制天空盒几何细分数,默认 128。该构造函数对非法输入直接抛错 THREE.GroundedSkybox: height, radius, and resolution must be positive.(见 examples/jsm/objects/GroundedSkybox.js),并默认把对象放在相机位置,所以官方建议手动设置 skybox.position.y = height 让"地面"落在原点(docs/pages/GroundedSkybox.html.md)。
Q3:为什么反射在 WebGL / WebGPU 两个页面观感几乎一致?
因为两者几何语义相同:一个在 CPU 侧变形网格(对象版把下半球按 1 − y²/(3y1²) 等公式平滑压扁,见 examples/jsm/objects/GroundedSkybox.js),一个在 GPU 侧变形采样方向(TSL 版)。殊途同归,差异只体现在适配 WebGL 还是 WebGPU 渲染后端。
相关资源
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0629
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python07
DragonOSDragonOS is an operating system developed from scratch using Rust, with Linux compatibility. It is designed for **Serverless** scenarios. 使用Rust从0自研内核,具有Linux兼容性的操作系统,面向云计算Serverless场景而设计。Rust00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
