首页
/ three.js 地面投影环境贴图 TSL 函数解析:getGroundProjectedNormal 使用与原理

three.js 地面投影环境贴图 TSL 函数解析:getGroundProjectedNormal 使用与原理

2026-09-08 09:22:50作者:钟日瑜

本篇文章围绕 three.js 官方文档 module-GroundedSkybox 所讲解的 TSL(Three Shading Language)工具函数 getGroundProjectedNormal 展开,讲解"地面投影环境贴图(Ground Projected Environment Mapping)"的核心原理、API 参数含义与完整接入流程。读完你将掌握:如何在 three.js WebGPU 渲染管线中用一行 TSL 节点把普通球面环境贴图"压"到无限地面平面上,让 HDR 背景与真实物体在同一场景中无缝衔接,并理解其背后的球体-圆盘相交数学实现。

three.js WebGPU 地面投影环境贴图示例效果:跑车位于沙滩地面,环境贴图被正确投影到水平地面之上

一、为什么需要"地面投影"环境贴图

在展示汽车、产品等室外场景时,开发者常用 HDR 全景图作为环境反射贴图(scene.environment)。但普通环境映射假设观察者位于贴图拍摄点,四周反射会呈现为封闭球面:无论相机视角如何压低,环境都没有"地平线以下被地面截断"的物理感,虚拟地面上的金属物体反射的是"天空的对面",视觉上像浮在空中。

getGroundProjectedNormal 解决的就是这个问题:它把世界坐标方向映射到一个"底部被地面圆盘裁剪的球面"上,即光线打到地面圆盘后即被视为从该命中点反射。这样环境贴图只在水平面以上呈现,以下部分与真实地面/放置平台自洽,营造出物体站在广袤地面上的真实效果。该项目提供的两个可运行参考页正是这一效果的直观演示:webgl_materials_envmaps_groundprojected.htmlwebgpu_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 = 15radius = 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 属节点/着色器层抽象,仓库中该工具函数的现成落地示例基于 WebGPURendererwebgpu_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 )heightradius 语义与 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 渲染后端。

相关资源

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

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.14 K
2.75 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
857
1.35 K
docsdocs
暂无描述
Markdown
898
5.82 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
531
596
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
921
1.84 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.8 K
1.02 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.36 K
1.46 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
1.02 K
519
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
548
391