Banner
大规模草地渲染

概述

运行一个计算着色器:计算着色器的每个线程计算一片草叶。首先,计算一个位置:叶片均匀分布在地形上,略有抖动。我们通过对该位置进行截锥体和距离剔除来检查草叶是否需要被渲染。如果草叶被渲染,我们继续,否则退出。每片草叶都属于特定的一丛。每种团块类型都有自己由艺术家创作的参数,决定草叶的高度、弯曲度和颜色。草叶的计算参数被打包到GrassBlade结构体中,并添加到AppendBuffer中。

_注意:_使用AppendBuffer而非RWStructuredBuffer更方便,因为每帧渲染的刀片数量因视锥体和距离剔除而变化。不过,也可以使用RWStructuredBuffer,正如Acerolas关于草地渲染的视频所示。

然后顶点着色器用 Graphics.DrawProceduralIndirect()渲染多片草叶。草叶 Mesh, 顶点颜色中包含了数据,比如顶点在叶片上的距离,以及它在草叶的哪一侧。

_注意:_顶点着色器需要知道要绘制多少刀片。这是通过使用 ComputeBuffer.CopyCount() 将 AppendBuffer 的大小复制到 DrawProceduralIndirect() 的 indirectArgsBuffer 中实现的。

在顶点着色器中,我们可以索引到位于GPU上的GrassBlades缓冲区,获取当前草叶的参数。草叶的放置基于由 GrassBlade 参数确定的贝塞尔曲线。由于我们使用贝塞尔曲线,也很容易获得顶点的法线,交叉曲线的切线(容易计算),与边向量相交。我们还通过根据风力移动贝塞尔曲线的点来在顶点着色器中对刀刃进行动画。

在片段着色器中,我们对程序生成的几何体进行光照(Phong Shading)和着色。

数据管线

流水线,并行,双缓冲 Buffer 策略,执行 8 个 Compute Shader

草叶建模与材质

Mesh 基础草叶网格模型创建

区分不同 LOD 的顶点数。顶点颜色记录叶片信息

顶点颜色标记区分 草叶左右侧

Tick

将草的顶点重新分配到草尖

草叶的大部分弯曲通常集中在草叶尖端。如果顶点分布均匀,就会导致用于代表锯片底部直线几何形状的顶点被浪费。通过调整参数,垂直点可以更向叶片尖端(需要的地方)分布。

短草Mesh,一个Mesh 折叠作为2颗草,顶点Color标记是左右侧草

High LOD ->lerp 平滑插值过渡到 Low LOD,注意过渡过程中,叶尖部分密度更高

Tick:短草Mesh,一个Mesh 折叠作为2颗草,顶点Color标记是左右侧草

曲面法线

刀刃的法线可以向外倾斜,以呈现曲率的外观。这样能让叶片看起来更立体、更饱满,同时又不会增加额外的顶点。

将法线与远距离的表面法线混合

即使采用了时间抗锯齿,草坪在远处仍可能非常颗粒感和锯齿,因为草叶不断移动,以及光泽高光。为缓解这一问题,可以将叶片的法线偏向远距离底层地形的法线。这导致噪点和颗粒感更少,因为法线在屏幕空间中变化较小。

一些处理远处草坪的小技巧:

  • 将远处地形的颜色与草地顶层颜色相匹配。这会造成远处草密度相同的错觉,尽管草被大量砍伐。
  • 远处让刀刃底部的环境遮挡逐渐消失。远处看到黑暗或阴影的斑点看起来很不自然。

侧视时重新对齐叶片的垂直方向

玩家经常正好侧面看草坪——导致草地非常稀薄,甚至看不见。在这种情况下,垂直点可以倾斜,更朝向玩家的视角。这是通过在锯片面大致垂直于视野矢量时,稍微移动垂直点来实现的。每个顶点在该点绕贝塞尔曲线的切线旋转。

Vertex Shader Bezier 曲线草叶

理论与应用

:::color1
一文全面解析贝塞尔曲线

6.3. 贝塞尔曲线 — 可视计算与交互概论

:::

贝塞尔曲线(Bézier curve) 是图形学、工程学、设计学等领域最常用的一类高阶曲线,由法国工程师皮埃尔·贝塞尔(Pierre Bézier)在20世纪60年代为汽车工业开发。贝塞尔曲线的定义非常简单,并且具有很好的几何性质。

三次贝塞尔曲线 方程

贝塞尔曲线导数

** **

Unity 中实践 Cubic Bezier 曲线函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
// =============================================================================================
// CubicBezier.hlsl
// 用途: 草片(GrassBlade)顶点阶段基于 t(0..1) 沿三次贝塞尔曲线插值获取中心线位置与切线。
// 公式:
// B(t) = (1-t)^3 * p0 + 3(1-t)^2 t * p1 + 3(1-t)t^2 * p2 + t^3 * p3, t ∈ [0,1]
// 导数(切线):
// B'(t) = -3(1-t)^2 p0 + (3(1-t)^2 - 6(1-t)t) p1 + (6(1-t)t - 3t^2) p2 + 3 t^2 p3
// 这里在实现中做了代数合并,见 CubicBezierTangent。
// 性能说明:
// - 预先计算 (1-t) 及平方可减少乘法。
// - 结果在顶点着色器中频繁调用,应保持无分支轻量。
// 数值注意:
// - t 需限制在 [0,1],若外部传入存在浮点误差,可在调用处 saturate(t)。
// - 若 p0≈p3 且 p1,p2 重合,曲线退化为短线段,切线仍可正常归一化。
// =============================================================================================
#ifndef CUBIC_BEZIER_INCLUDED
#define CUBIC_BEZIER_INCLUDED

float3 CubicBezier(float3 p0, float3 p1, float3 p2, float3 p3, float t)
{
float omt = 1 - t; // one minus t
float omt2 = omt * omt; // (1-t)^2
float t2 = t * t; // t^2

// 按标准三次贝塞尔展开
return p0 * (omt * omt2) + // (1-t)^3 p0
p1 * (3 * omt2 * t) + // 3(1-t)^2 t p1
p2 * (3 * omt * t2) + // 3(1-t) t^2 p2
p3 * (t * t2); // t^3 p3
}

float3 CubicBezierTangent(float3 p0, float3 p1, float3 p2, float3 p3, float t)
{
float omt = 1 - t;
float omt2 = omt * omt;
float t2 = t * t;

// 经过整理后的导数表达式 (见上面注释中的原始形式),再归一化得到单位切线
float3 tangent =
p0 * (-omt2) +
p1 * (3 * omt2 - 2 * omt) +
p2 * (-3 * t2 + 2 * t) +
p3 * (t2);

return normalize(tangent); // 若长度接近 0,外层使用时应考虑回退到 (0,1,0) 等默认方向
}

#endif // CUBIC_BEZIER_INCLUDED

对草叶形状进行参数化调整

基于 Bezier 曲线导数的法线计算

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
float3 GetP0() { return float3(0,0,0); } // 根部锚点 (局部空间原点)

float3 GetP3(float height, float tilt)
{
// 根据高度与倾斜角构建末端点: 在局部 X-Y 平面内分解, 保持总长度为 height
float p3y = tilt * height; // Y 向提升 (倾斜)
float p3x = sqrt(height * height - p3y * p3y); // 勾股保证长度
return float3(-p3x, p3y, 0); // 使用 -X 方向作为默认向前方向 (可与 rotAngle 旋转)
}

void GetP1P2P3(
float3 p0, // 贝塞尔起点
inout float3 p3, // 末端点 (会被风影响侧向偏移)
float bend, // 主弯曲强度
float hash, // 随机种子 (决定风相位)
float windForce, // 风强度 (缩放风幅度)
out float3 p1, // 输出控制点 1
out float3 p2) // 输出控制点 2
{
// 初始控制点线性插值 (三次贝塞尔近似自然曲线)
p1 = lerp(p0, p3, 0.33);
p2 = lerp(p0, p3, 0.66);

float3 bladeDir = normalize(p3 - p0); // 主方向
float3 bezCtrlOffsetDir = normalize(cross(bladeDir, float3(0,0,1))); // 垂直于 Z 的侧向 (局部左右)

// 基础弯曲: 按 bend 沿侧向推控制点
p1 += bezCtrlOffsetDir * bend * _p1Offset;
p2 += bezCtrlOffsetDir * bend * _p2Offset;

// 风动画: 对中段(p2)与末端(p3)叠加不同相位/幅度的正弦偏移, 模拟柔性传递
float p2WindEffect = sin((_Time.y + hash * 2 * PI) * _WaveSpeed + 0.66 * 2 * PI * _SinOffsetRange) * windForce;
p2WindEffect *= 0.66 * _WaveAmplitude; // 中段幅度稍小

float p3WindEffect = sin((_Time.y + hash * 2 * PI) * _WaveSpeed + 1.0 * 2 * PI * _SinOffsetRange) * windForce + _PushTipForward * (1 - bend);
p3WindEffect *= _WaveAmplitude; // 顶端幅度最大

p2 += bezCtrlOffsetDir * p2WindEffect;
p3 += bezCtrlOffsetDir * p3WindEffect; // 顶端偏移更显著
}

//...

// 2. 生成贝塞尔控制点
float3 p0 = GetP0();
float3 p3 = GetP3(height, tilt);
float3 p1, p2; GetP1P2P3(p0, p3, bend, hash, windForce, p1, p2);

// 3. 根据索引获取基础顶点属性 (颜色通道编码: r=t 曲线位置, g=side [-1,1])
// ...

// 4. 计算曲线上中心点 + 宽度/侧向偏移
// ...

// 5. 曲线切线 & 法线 (初步)
float3 tangent = CubicBezierTangent(p0, p1, p2, p3, t);
float3 normal = normalize(cross(tangent, float3(0,0,1)));

倾斜法线技巧与圆润视觉效果

圆润视觉处理使得叶片更有厚度

将法线边缘向两侧倾斜,得到更自然,圆润的叶片

not shift

shift blade verts in view space,增加视觉粗度

玩家经常正好侧面看草坪——导致草地非常稀薄,甚至看不见。在这种情况下,垂直点可以倾斜,更朝向玩家的视角。这是通过在锯片面大致垂直于视野矢量时,稍微移动垂直点来实现的。每个顶点在该点绕贝塞尔曲线的切线旋转。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 4. 计算曲线上中心点 + 宽度/侧向偏移
float3 centerPos = CubicBezier(p0, p1, p2, p3, t);
float width = blade.width * (1 - _TaperAmount * t); // 越靠顶越窄
float side = vertColor.g * 2 - 1; // 侧向符号
float3 position = centerPos + float3(0, 0, side * width);

// 5. 曲线切线 & 法线 (初步)
float3 tangent = CubicBezierTangent(p0, p1, p2, p3, t);
float3 normal = normalize(cross(tangent, float3(0,0,1)));

// 6. 增强法线 (使光照更柔)
float3 curvedNorm = normal;
curvedNorm.z += side * _CurvedNormalAmount;
curvedNorm = normalize(curvedNorm);

将法线与远距离的表面法线混合

即使采用了时间抗锯齿,草坪在远处仍可能非常颗粒感和锯齿,因为草叶不断移动,以及光泽高光。为缓解这一问题,可以将叶片的法线偏向远距离底层地形的法线。这导致噪点和颗粒感更少,因为法线在屏幕空间中变化较小。

实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66

Varyings vert(Attributes IN)
{
Varyings OUT;
// 1. 读取实例数据
GrassBlade blade = _GrassBlades[IN.instanceID];
float bend = blade.bend;
float height = blade.height;
float tilt = blade.tilt;
float hash = blade.hash;
float windForce = blade.windForce;

// 2. 生成贝塞尔控制点
float3 p0 = GetP0();
float3 p3 = GetP3(height, tilt);
float3 p1, p2; GetP1P2P3(p0, p3, bend, hash, windForce, p1, p2);

// 3. 根据索引获取基础顶点属性 (颜色通道编码: r=t 曲线位置, g=side [-1,1])
int positionIndex = Triangles[IN.vertexID];
float4 vertColor = Colors[positionIndex];
float2 uv = Uvs[positionIndex];
float t = vertColor.r;

// 4. 计算曲线上中心点 + 宽度/侧向偏移
float3 centerPos = CubicBezier(p0, p1, p2, p3, t);
float width = blade.width * (1 - _TaperAmount * t); // 越靠顶越窄
float side = vertColor.g * 2 - 1; // 侧向符号
float3 position = centerPos + float3(0, 0, side * width);

// 5. 曲线切线 & 法线 (初步)
float3 tangent = CubicBezierTangent(p0, p1, p2, p3, t);
float3 normal = normalize(cross(tangent, float3(0,0,1)));

// 6. 增强法线 (使光照更柔)
float3 curvedNorm = normal;
curvedNorm.z += side * _CurvedNormalAmount;
curvedNorm = normalize(curvedNorm);

// 7. 旋转:先侧弯 (沿切线旋转), 再整体 Y 轴朝向旋转
float angle = blade.rotAngle;
float sideBend = blade.sideBend;
float3x3 rotMat = RotAxis3x3(-angle, float3(0,1,0));
float3x3 sideRot = RotAxis3x3(sideBend, normalize(tangent));

position -= centerPos; // 局部化 -> 侧弯
normal = mul(sideRot, normal);
curvedNorm = mul(sideRot, curvedNorm);
position = mul(sideRot, position);
position += centerPos; // 还原

normal = mul(rotMat, normal); // 全局朝向旋转
curvedNorm = mul(rotMat, curvedNorm);
position = mul(rotMat, position);

// 8. 平移到实例世界位置
position += blade.position;

// 9. 输出插值数据
OUT.positionCS = TransformWorldToHClip(position);
OUT.curvedNorm = curvedNorm;
OUT.originalNorm = normal;
OUT.positionWS = position;
OUT.uv = uv;
OUT.t = t;
return OUT;
}

Pixel Shader 草叶材质与 PBR 着色

  • Output material data to G buffers

  • Gloss-1D texture

  • Diffuse-Two textures

    • 1D texture for vein [茎叶]
    • 2D texture for color and alternate colors
  • Translucency-Constant value

  • A0-Constant value

AlbedoNormalRoughnessMetallic

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
//片元着色器PBR着色部分
half4 frag(Varyings i, bool isFrontFace : SV_IsFrontFace) : SV_Target
{
// 1. 法线: 反面使用反射纠正法线方向,减少背面光照突变
float3 n = isFrontFace ? normalize(i.curvedNorm) : -reflect(-normalize(i.curvedNorm), normalize(i.originalNorm));

// 2. 主光 + 相机向量
Light mainLight = GetMainLight(TransformWorldToShadowCoord(i.positionWS));
float3 v = normalize(GetCameraPositionWS() - i.positionWS);

// 3. 纹理采样 + 顶底渐变
float3 grassAlbedo = saturate(_GrassAlbedo.Sample(sampler_GrassAlbedo, i.uv));
float4 grassCol = lerp(_BottomColor, _TopColor, i.t); // 基础渐变: 根 -> 顶
float3 albedo = grassCol.rgb * grassAlbedo;

// 4. Gloss/粗糙度: 简单反转控制 (可扩展为金属度等)
float gloss = (1 - _GrassGloss.Sample(sampler_GrassGloss, i.uv).r) * 0.2;

// 5. 环境光 (球谐) + 主光 BRDF
half3 GI = SampleSH(n);
BRDFData brdfData; half alpha = 1;
InitializeBRDFData(albedo, 0, half3(1,1,1), gloss, alpha, brdfData);
float3 directBRDF = DirectBRDF(brdfData, n, mainLight.direction, v) * mainLight.color;

// 6. 合成最终颜色 (可扩展: 侧光色调、次表面、风高光闪烁等)
float3 finalColor = GI * albedo + directBRDF * (mainLight.shadowAttenuation * mainLight.distanceAttenuation);
return half4(finalColor, grassCol.a); // 保持 alpha 以便未来做透明/半裁剪
}

自然草丛效果

在对马岛中为了**模拟自然界中草丛生长的分布**,提出了Clump的概念,就是一定范围内的草的朝向等属性是基本相同的,它们有统一的中心点,可以向中心点偏移来与其他Clump拉开距离。

使用**Voronoi噪声**能比较好的表现这种效果,噪声中需要使用三个通道来存储我们所需要的数据,第一个是该Clump的参数ID一个通道,第二个是Clump的中心位置两个通道。这里的Clump参数结构体用于控制一个Clump内草的行为。

Voronoi 图案与 Shader 实现

Noise

Clumping

C# 草丛 Clumping 数据结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

// 草簇参数:由 Voronoi 分区映射到不同 index -> 控制局部风格差异
struct ClumpParameters
{
float pullToCentre; // 趋向簇中心的插值强度
float pointInSameDirection; // 对齐共享朝向的强度(提高一致性)
float baseHeight; // 基础高度
float heightRandom; // 高度随机幅度
float baseWidth; // 基础宽度
float widthRandom; // 宽度随机幅度
float baseTilt; // 基础倾斜
float tiltRandom; // 倾斜随机幅度
float baseBend; // 基础弯曲
float bendRandom; // 弯曲随机幅度
};

Compute Shader 实现草丛 Clumping

1
2
3
4
5
6
7
8
9
10
11
12
13
//Compute Shader
// 3. 簇 (Voronoi) 数据: x=簇索引, yz=簇中心局部偏移
float2 clumpUV =position.xz * float2(_ClumpScale.xx);
float3 clumpData = ClumpTex.SampleLevel(samplerClumpTex, clumpUV, 0).xyz;

float clumpParamsIndex = clumpData.x;
clumpParamsIndex = clamp(clumpParamsIndex, 0, _NumClumpParameters - 1);
ClumpParameters bladeParameters =_ClumpParameters[int(clumpParamsIndex)];

// 根据 pullToCentre 向簇中心收缩 -> 增强聚集感
float2 clumpCentre = (clumpData.yz +floor(clumpUV)) /float2(_ClumpScale.xx);
position.xz = lerp(position.xz,clumpCentre,bladeParameters.pullToCentre);
position.xz +=_TerrainPosition.xz;

Vertex Shader 支持草丛效果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//GrassBlade Shader
// 7. 旋转:先侧弯 (沿切线旋转), 再整体 Y 轴朝向旋转
float angle = blade.rotAngle;
float sideBend = blade.sideBend;
float3x3 rotMat = RotAxis3x3(-angle, float3(0,1,0));
float3x3 sideRot = RotAxis3x3(sideBend, normalize(tangent));

position -= centerPos; // 局部化 -> 侧弯
normal = mul(sideRot, normal);
curvedNorm = mul(sideRot, curvedNorm);
position = mul(sideRot, position);
position += centerPos; // 还原

normal = mul(rotMat, normal); // 全局朝向旋转
curvedNorm = mul(rotMat, curvedNorm);
position = mul(rotMat, position);

// 8. 平移到实例世界位置
position += blade.position;

程序化草地放置与高效渲染

使用 GPU Instancing 实例化渲染与配合地形高度去渲染草地

Compute Shader 程序化草叶放置

Placing a Grass Blade 放置过程

Generating a Grass Blade 数据结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 草实例结构,CPU 侧须保证 stride 一致 (14 float)
struct GrassBlade
{
float3 position; // 世界空间根部位置
float rotAngle; // 绕 Y 轴旋转角 (朝向)
float hash; // 随机哈希 (用于 Shader 中次级随机)
float height; // 草高度
float width; // 宽度(可在顶点阶段拉伸横向 billboard)
float tilt; // 根部倾斜角(整体歪斜)
float bend; // 主弯曲量(随风/随机)
float3 surfaceNorm;// 地表法线(用于与地形贴合 & Lighting)
float windForce; // 当前风力(幅度)
float sideBend; // 侧向弯曲(与相机对齐减少穿插感)
};

⭐️IndirectDraw 大规模实例化草叶渲染

使用绘制参数直接在GPU上直接进行绘制 **[ 间接绘制 GPU Instancing] **是提升性能最关键的步骤,在Unity中我们可以调用[<u><font style="color:#2F4BDA;background-color:rgb(248, 248, 250);">Graphics.DrawProceduralIndirect</u>](https://docs.unity3d.com/6000.0/Documentation/ScriptReference/Graphics.DrawProceduralIndirect.html)实现这项功能.

1
2
3
4
5
6
7
8
9
10
11
12
public static void DrawProceduralIndirect(
Material material, //草所使用的材质
Bounds bounds, //所渲染的草地的包围盒
MeshTopology topology,//这个参数可以指定五项,分别是Triangles、Quads、Lines、LineStrip、Points
ComputeBuffer bufferWithArgs,//绘制参数
int argsOffset = 0, //对于bufferWithArgs参数的偏移
Camera camera = null, //为null就是所有相机都会执行绘制,否则就执行指定相机的绘制
MaterialPropertyBlock properties = null,//想要单独设置的材质参数
ShadowCastingMode castShadows = ShadowCastingMode.On,
bool receiveShadows = true,
int layer = 0 //指定绘制对象所属的层级
)

对于<font style="color:rgb(25, 27, 31);background-color:rgb(248, 248, 250);">bufferWithArgs这个参数需要着重的说明一下, 在创建这个传递这个参数的<font style="color:rgb(25, 27, 31);background-color:rgb(248, 248, 250);">ComputeBuffer实例时,必须要指定它为<font style="color:rgb(25, 27, 31);background-color:rgb(248, 248, 250);">ComputeBufferType.IndirectArguments

它一般有四个参数(五个的时候是用的<font style="color:rgb(25, 27, 31);background-color:rgb(248, 248, 250);">DrawProceduralIndirect的另一个重载),分别是

  • 每个实例的顶点数
  • 实例数
  • 起始顶点位置
  • 起始实例位置

后面的两个参数一般指定为0,顶点数也容易搞定,主要是**实例数的确定**可能有一些困难。

但是幸好有[<font style="color:rgb(9, 64, 142);background-color:rgb(248, 248, 250);">AppendStructuredBuffer](https://zhida.zhihu.com/search?content_id=238864402&content_type=Article&match_order=1&q=AppendStructuredBuffer&zhida_source=entity)的出现,它自带计数器,我们可以在compute shader中将数据处理完之后放到<font style="color:rgb(25, 27, 31);background-color:rgb(248, 248, 250);">AppendStructuredBuffer中,它会记录其中所存放的数据的数量, 然后我们利用<font style="color:rgb(25, 27, 31);background-color:rgb(248, 248, 250);">ComputeBuffer.CopyCount可以将实例数复制给<font style="color:rgb(25, 27, 31);background-color:rgb(248, 248, 250);">bufferWithArgs的第二个参数。需要注意的是在每次调用compute shader时需要将<font style="color:rgb(25, 27, 31);background-color:rgb(248, 248, 250);">count置为0。

1
2
3
4
5
6
7
8
_grassPropertiesBuffer.SetCounterValue(0);

GrassComputeShader.Dispatch(0,groupSize,groupSize,1);

ComputeBuffer.CopyCount(_grassPropertiesBuffer,_argsBuffer,sizeof(int));

Graphics.DrawProceduralIndirect(GrassMaterial,_grassInstanceBounds,MeshTopology.Triangles,_argsBuffer,
0,null,null,ShadowCastingMode.Off,true,gameObject.layer);

由于**使用参数直接进行绘制**,不存在实际的网格,所以在shader编写时需要注意传入参数的绑定。我们提前将需要的数据都传入<font style="color:rgb(25, 27, 31);">StructuredBuffer中,然后根据对应的<font style="color:rgb(25, 27, 31);">ID可以获取到其中的数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
StructuredBuffer<GrassPropertiesStruct>GrassProperties;
StructuredBuffer<int> Triangles;
StructuredBuffer<float4> Colors;
StructuredBuffer<float2> Uvs;

//这里第一参数对应之前设置的顶点数,第二个参数对应实例的数
Varyings vert(uint vertex_id: SV_VertexID, uint instance_id: SV_InstanceID)
{
Varyings OUT;
GrassPropertiesStruct grassProperties=GrassProperties[instance_id];

int index=Triangles[vertex_id];
float4 color=Colors[index];
OUT.uv=Uvs[index];

//....
}

1
2
3
4
5
6
7
8
private void RenderGrass()
{
// 将 AppendBuffer 内实例数量写入 argsBuffer[1]
ComputeBuffer.CopyCount(grassBladesBuffer, argsBuffer, sizeof(int));
// 基于 GPU 生成的实例数量进行间接绘制 (无需 CPU 读取)
Graphics.DrawProceduralIndirect(material, bounds, MeshTopology.Triangles, argsBuffer,
0, null, null, UnityEngine.Rendering.ShadowCastingMode.Off, true, gameObject.layer);
}

Blade Hash 的计算与位置随机性

1
2
3
4
5
6
7
8
9
//Compute Shader
// 2. 初始平面位置 + 间距
float3 position = float3(id.x,0,id.y) * _GrassSpacing;
// 随机抖动 (避免网格感)
float2 hash = HashFloat2(id.xy);
float2 jitter = ((hash * 2) - 1) * _JitterStrength;
position.xz += jitter;
// Tile 偏移 -> 世界局部 (仍在 Terrain 坐标系)
position.xz += (_TilePosition.xz - _TerrainPosition.xz);

支持Indirect Draw 的草叶 Shader

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//GrassBlade Shader

GrassBlade blade = _GrassBlades[IN.instanceID];
float bend = blade.bend;
float height = blade.height;
float tilt = blade.tilt;
float hash = blade.hash;
float windForce = blade.windForce;

// 2. 生成贝塞尔控制点
float3 p0 = GetP0();
float3 p3 = GetP3(height, tilt);
float3 p1, p2; GetP1P2P3(p0, p3, bend, hash, windForce, p1, p2);

// 3. 根据索引获取基础顶点属性 (颜色通道编码: r=t 曲线位置, g=side [-1,1])
int positionIndex = Triangles[IN.vertexID];
float4 vertColor = Colors[positionIndex];
float2 uv = Uvs[positionIndex];
float t = vertColor.r;

基于地形数据的草叶放置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//Compute Shader

float SampleHeight(float2 normalizedPos) // 采样地形高度 (归一化坐标)
{
float height = UnpackHeightmap(_HeightMap.SampleLevel(LinearClampSampler, normalizedPos, 0));
return height * _HeightMapMultiplier * 2; // *2: 与 CPU 侧地形放缩策略保持一致
}

float SampleGrass(float2 normalizedPos) // 采样 Detail/Alpha 图 (草密度或涂绘层)
{
float value = _DetailMap.SampleLevel(LinearClampSampler, normalizedPos, 0).r;
return value; // 约定: r 通道代表草分布权重
}


// 4. 地形高度 / 密度采样
float2 normalizedPos = (position.xz - _TerrainPosition.xz) / _HeightMapScale;
if (SampleGrass(normalizedPos) < 0.1) return; // 密度阈值剔除
position.y =_TerrainPosition.y + SampleHeight(normalizedPos);

基于距离与视锥的剔除优化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
//Compute Shader

float3 _WSpaceCameraPos;
float _DistanceCullStartDist; // 距离裁剪开始(保持满密度)
float _DistanceCullEndDist; // 距离裁剪结束(完全消失)
float _DistanceCullMinimumGrassAmount; // 渐隐过程中的最小保留比例

float4x4 _VP_MATRIX;
float _FrustumCullNearOffset; // 视锥近裁面偏移(减少近距离突然消失)
float _FrustumCullEdgeOffset; // 视锥左右裁剪放宽(避免边缘抖动)

//...

uint DistanceCull(float3 worldPos, float hash)
{
float d = distance(worldPos, _WSpaceCameraPos);
float distanceSmoothStep = 1 - smoothstep(_DistanceCullStartDist, _DistanceCullEndDist, d);
// 在最小保留比例基础上平滑过渡,避免硬裁剪
distanceSmoothStep = (distanceSmoothStep * (1 - _DistanceCullMinimumGrassAmount)) + _DistanceCullMinimumGrassAmount;
// 利用 hash 做随机化抽样 -> 渐隐视觉更平滑
return hash > 1 - distanceSmoothStep ? 1 : 0;
}

uint FrustumCull(float3 worldPos)
{
float4 clipPos = mul(_VP_MATRIX, float4(worldPos, 1));
// 裁剪空间判断,放宽部分边界 (_FrustumCullEdgeOffset / _FrustumCullNearOffset)
return (clipPos.z > clipPos.w
|| clipPos.z < 0
|| clipPos.x < -clipPos.w + _FrustumCullEdgeOffset
|| clipPos.x > clipPos.w - _FrustumCullEdgeOffset
|| clipPos.y < -clipPos.w + _FrustumCullNearOffset
|| clipPos.y > clipPos.w
)?0:1;
}


//...
// 5. 距离 & 视锥裁剪
uint distanceCull = DistanceCull(position, hash.x);
uint frustumCull = FrustumCull(position);
if (distanceCull ==1 && frustumCull ==1)
{
//...
}

⭐Tile-Based 草地渲染优化

将地形划分为图块 Tile,

对马岛大概每 39cm 一纹素

草地 Tile 的概念与数据结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
using UnityEngine;

namespace Zerls.GrassSystem
{
public partial class GrassSystem :MonoBehaviour
{
// Tile: 草实例生成与剔除的最小逻辑单元(可被合并形成低 LOD 区域)
// 含义说明:
// terrain 归属的 Terrain(支持多 Terrain)
// bounds 该 Tile 在世界空间的包围盒,用于视锥裁剪与 Gizmo 调试
// gridPosition 在 Terrain 分块网格中的格子坐标 (x,z)
// spaceMultiplier 草间距倍率:=1 表示近圈高密度;=2(或更大) 表示合并后的外圈低密度区域
// xResolutionDivisor X 方向分辨率除数:>1 时实际生成草的网格采样会被降采样(减少实例)
// zResolutionDivisor Z 方向分辨率除数:同上,对应 Z 方向
// 组合逻辑: 近圈使用 (spaceMultiplier=1, divisor=1);外圈合并 2x2 时 spaceMultiplier=2,必要时边缘再提升 divisor 以继续降采样。
// 最终 Compute 调度线程数 = ceil(tileResolution / 8 * xResolutionDivisor)(同 Z),而草实际间距 = baseSpacing * spaceMultiplier。
private struct Tile
{
public Terrain terrain; // 归属 Terrain
public Bounds bounds; // 世界包围盒(剔除 / Debug)
public Vector2Int gridPosition; // 网格坐标 (tileX, tileZ)
public float spaceMultiplier; // 草间距倍率 (合并 LOD 时增大,降低密度)
public int xResolutionDivisor; // X 向采样分辨率除数 (越大 -> 实例越少)
public int zResolutionDivisor; // Z 向采样分辨率除数

// 构造函数参数:
// t 归属 Terrain
// b 该 Tile 的包围盒
// pos 网格坐标
// mul 草间距倍率 (默认 1,高密度)
// xd X 分辨率除数 (默认 1,不降采样)
// zd Z 分辨率除数 (默认 1,不降采样)
public Tile(Terrain t, Bounds b, Vector2Int pos,float mul = 1,int xd = 1,int zd = 1)
{
terrain = t;
bounds = b;
gridPosition = pos;
spaceMultiplier = mul;
xResolutionDivisor = xd;
zResolutionDivisor = zd;
}
}
}
}

实现

  • 基于 Tile 的草叶渲染
  • 支持多地形和 LOD 的 Tile 生成
  • 不同 LOD Tile 之间的平滑过渡

高密度地块 过渡到低密度地块 -> 种植数量减少一半

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
//GrassSystem Tile 相关片段

//tileCount 单个Terrain的Tile纵向/竖向格子数量

void Update()
{
UpdateGrassTiles(); // 计算 Tile + 合并 LOD
UpdateGpuParameters(); // 为每个可见 Tile Dispatch Compute
}

private void UpdateGrassTiles()
{
tilesToRender.Clear();
//=============== 支持多地形和 LOD 的 Tile 生成=============
foreach (Terrain terrain in terrains)
{
if (terrain !=null)
{
UpdateSurroundingTilesForTerrain(terrain);
}
}
UpdateVisibleTiles(); // 视锥裁剪
}
//==================================================核心函数=========================================
/// <summary>
/// 更新单个 Terrain 周围的 Tile,采用分层 LOD 策略
/// 中心 4x4 区域使用高分辨率单 Tile,外圈使用 2x2 合并的低分辨率 Tile
/// </summary>
private void UpdateSurroundingTilesForTerrain(Terrain terrain)
{
// 获取 Terrain 尺寸并计算单个 Tile 的世界空间大小
Vector3 terrainSize = terrain.terrainData.size;
tileSizeZ = tileSizeX = terrainSize.x / tileCount; // 假设方形 terrain

// 将相机位置转换到 Terrain 本地坐标系
Vector3 cameraPositionInTerrainSpace = cam.transform.position - terrain.transform.position;

// 根据相机位置计算其所在的 Tile 网格索引
int cameraTileXIndex = Mathf.FloorToInt(cameraPositionInTerrainSpace.x / tileSizeX);
int cameraTileZIndex = Mathf.FloorToInt(cameraPositionInTerrainSpace.z / tileSizeZ);

// 采用 8x8 环绕区域策略 (中心 4x4 为高分辨率, 外圈 2x2 合并成更大 Tile 作为低 LOD)
// 范围检查以避免无限扩张
if (cameraTileXIndex >= -4 && cameraTileXIndex < tileCount + 3 &&
cameraTileZIndex >= -4 && cameraTileZIndex < tileCount + 3)
{
// 使用 HashSet 去重外圈合并 Tile 的起始位置 (避免重复添加相同的 2x2 块)
HashSet<Vector2Int> mergedTileGridPositions = new HashSet<Vector2Int>();

// 遍历相机周围 8x8 的 Tile 网格
for (int xIndex = cameraTileXIndex - 3; xIndex <= cameraTileXIndex + 4; xIndex++)
{
for (int zIndex = cameraTileZIndex - 3; zIndex <= cameraTileZIndex + 4; zIndex++)
{
Vector2Int currentGridPosition = new Vector2Int(xIndex, zIndex);

// 判断是否在中心 4x4 高精度区域内且在 Terrain 边界范围内
if (IsStandardTile(xIndex, cameraTileXIndex) && IsStandardTile(zIndex, cameraTileZIndex) && IsTileWithinTerrainBounds(xIndex, zIndex))
{
// 添加高分辨率单 Tile(完整分辨率,无合并)
AddStandardTile(terrain, currentGridPosition);
}
else
{
// 计算该 Tile 所属的 2x2 合并块的起始位置
(Vector2Int mergedTileStartPosition, bool isMerged) = CalculateMergedTileStartPosition(xIndex, zIndex, cameraTileXIndex, cameraTileZIndex);

// 若该 Tile 需要合并,记录其 2x2 块的起始位置(HashSet 自动去重)
if (isMerged)
{
mergedTileGridPositions.Add(mergedTileStartPosition);
}
}
}
}

// 根据去重后的合并 Tile 起始位置集合,创建外圈低分辨率 Tile
AddMergedTiles(terrain, mergedTileGridPositions);
}
}
/// <summary>
/// 根据当前 Tile 索引与相机所在 Tile 索引,计算该 Tile 是否属于外圈需要合并的范围
/// 若需要合并,返回其所属 2x2 块的起始格坐标(以相机为中心的 8x8 网格中)以及一个标志表示是否需要合并
/// </summary>
/// <param name="xIndex"></param>
/// <param name="zIndex"></param>
/// <param name="cameraTileXIndex"></param>
/// <param name="cameraTileZIndex"></param>
/// <returns></returns>
private (Vector2Int, bool) CalculateMergedTileStartPosition(int xIndex, int zIndex, int cameraTileXIndex, int cameraTileZIndex)
{
// 计算外圈 2x2 合并 Tile 的起始格坐标
// 返回值: (合并块起始位置, 是否需要合并)
Vector2Int mergedStartPos = Vector2Int.zero;
bool isMerged = false;

// 左侧外圈(相机左方两列)
if (xIndex <= cameraTileXIndex - 2)
{
int startZIndex = cameraTileZIndex - 3; // 外圈纵向起始位置
int groupZIndex = (zIndex - startZIndex) / 2; // 计算当前 Tile 属于第几个 2x2 块
mergedStartPos = new Vector2Int(cameraTileXIndex - 3, startZIndex + groupZIndex * 2);
isMerged = true;
}
// 右侧外圈(相机右方两列)
else if (xIndex >= cameraTileXIndex + 3)
{
int startZIndex = cameraTileZIndex - 3;
int groupZIndex = (zIndex - startZIndex) / 2;
mergedStartPos = new Vector2Int(cameraTileXIndex + 3, startZIndex + groupZIndex * 2);
isMerged = true;
}
// 上侧外圈(相机上方两行,仅限标准 Tile 列范围内)
else if (zIndex <= cameraTileZIndex - 2 && IsStandardTile(xIndex, cameraTileXIndex))
{
int startXIndex = cameraTileXIndex - 1; // 标准区域横向起始位置
int groupXIndex = (xIndex - startXIndex) / 2; // 计算当前 Tile 属于第几个 2x2 块
mergedStartPos = new Vector2Int(startXIndex + groupXIndex * 2, cameraTileZIndex - 3);
isMerged = true;
}
// 下侧外圈(相机下方两行,仅限标准 Tile 列范围内)
else if (zIndex >= cameraTileZIndex + 3 && IsStandardTile(xIndex, cameraTileXIndex))
{
int startXIndex = cameraTileXIndex - 1;
int groupXIndex = (xIndex - startXIndex) / 2;
mergedStartPos = new Vector2Int(startXIndex + groupXIndex * 2, cameraTileZIndex + 3);
isMerged = true;
}

return (mergedStartPos, isMerged);
}
private bool IsStandardTile(int tileIndex, int cameraTileIndex)
{
return tileIndex >=cameraTileIndex -1 && tileIndex <= cameraTileIndex +2; // 以相机为中心的 4 列/行
}

private bool IsTileWithinTerrainBounds(int xIndex,int zIndex)
{
return xIndex >= 0 && xIndex < tileCount && zIndex >= 0 && zIndex < tileCount;
}

private void AddStandardTile(Terrain terrain, Vector2Int gridPosition)
{
Bounds tileBounds = CalculateTileBounds(terrain, gridPosition.x, gridPosition.y);
tilesToRender.Add(new Tile(terrain,tileBounds,gridPosition,1f,1,1));
}

private void AddMergedTiles(Terrain terrain, HashSet<Vector2Int> mergedTileGridPositions)
{
foreach (Vector2Int gridPosition in mergedTileGridPositions)
{
if (gridPosition.x <=-2 || gridPosition.x >=tileCount ||gridPosition.y <=-2 || gridPosition.y >=tileCount) continue; // 超出有效范围

int xResolutionDivisor = 1;
int zResolutionDivisor = 1;
int posX = gridPosition.x;
int posY = gridPosition.y;

// 边缘可能需要减半分辨率 (通过 divisor 控制)
if (gridPosition.x == -1) { xResolutionDivisor = 2; posX = 0; }
if (gridPosition.x == tileCount - 1) { xResolutionDivisor = 2; }
if (gridPosition.y == -1) { zResolutionDivisor = 2; posY = 0; }
if (gridPosition.y == tileCount - 1) { zResolutionDivisor = 2; }

Bounds mergedBounds = CalculateTileBounds(terrain, posX, posY, 2f / xResolutionDivisor, 2f / zResolutionDivisor);
tilesToRender.Add(new Tile(terrain,mergedBounds,new Vector2Int(posX,posY),2f,xResolutionDivisor,zResolutionDivisor));
}
}

private Bounds CalculateTileBounds(Terrain terrain, int tileXIndex, int tileZIndex,float tileScaleX =1.0f,float tileScaleZ =1.0f)
{
// Y 方向留出上下空间避免高度变化被裁剪
Vector3 min = terrain.transform.position + new Vector3(tileXIndex * tileSizeX, -10f, tileZIndex * tileSizeZ);
Vector3 max = min + new Vector3(tileSizeX * tileScaleX, 20f, tileSizeZ * tileScaleZ);
Bounds bounds = new Bounds();
bounds.SetMinMax(min, max);
return bounds;
}
private void AddMergedTiles(Terrain terrain, HashSet<Vector2Int> mergedTileGridPositions)
{
foreach (Vector2Int gridPosition in mergedTileGridPositions)
{
if (gridPosition.x <=-2 || gridPosition.x >=tileCount ||gridPosition.y <=-2 || gridPosition.y >=tileCount) continue; // 超出有效范围

int xResolutionDivisor = 1;
int zResolutionDivisor = 1;
int posX = gridPosition.x;
int posY = gridPosition.y;

// 边缘可能需要减半分辨率 (通过 divisor 控制)
if (gridPosition.x == -1) { xResolutionDivisor = 2; posX = 0; }
if (gridPosition.x == tileCount - 1) { xResolutionDivisor = 2; }
if (gridPosition.y == -1) { zResolutionDivisor = 2; posY = 0; }
if (gridPosition.y == tileCount - 1) { zResolutionDivisor = 2; }

Bounds mergedBounds = CalculateTileBounds(terrain, posX, posY, 2f / xResolutionDivisor, 2f / zResolutionDivisor);
tilesToRender.Add(new Tile(terrain,mergedBounds,new Vector2Int(posX,posY),2f,xResolutionDivisor,zResolutionDivisor));
}
}

private void UpdateVisibleTiles()
{
visibleTiles.Clear();
Plane[] frustumPlanes = GeometryUtility.CalculateFrustumPlanes(cam);
foreach (Tile tile in tilesToRender)
{
if (IsVisibleInFrustum(frustumPlanes,tile.bounds))
{
visibleTiles.Add(tile); // 仅保留视锥内 Tile
}
}
}

private bool IsVisibleInFrustum(Plane[] planes, Bounds bounds)
{
return GeometryUtility.TestPlanesAABB(planes, bounds);
}

private void UpdateGpuParameters()
{
grassBladesBuffer.SetCounterValue(0); // 每帧开始清空 Append 计数
computeShader.SetVector(worldSpaceCameraPositionID,cam.transform.position);

Matrix4x4 projectionMatrix = GL.GetGPUProjectionMatrix(cam.projectionMatrix, false);
Matrix4x4 viewProjectionMatrix = projectionMatrix * cam.worldToCameraMatrix;
computeShader.SetMatrix(vpMatrixID,viewProjectionMatrix);
computeShader.SetFloat(TimeID,Time.time);


foreach (Tile tile in visibleTiles)
{
SetupComputeShaderForTile(tile);
// Compute 内核假设线程组大小为 (8,8,1),这里依据 tileResolution / divisor 取整上
int threadGroupsX = Mathf.CeilToInt(tileResolution / 8f * tile.xResolutionDivisor);
int threadGroupsZ = Mathf.CeilToInt(tileResolution / 8f * tile.zResolutionDivisor);
computeShader.Dispatch(0, threadGroupsX, threadGroupsZ, 1);
}
}


private void SetupComputeShaderForTile(Tile tile)
{
//=======支持多地形和 LOD 的 Tile 生成=======
Terrain terrain = tile.terrain;
//==========不同 LOD Tile 之间的平滑过渡===============
if (tile.spaceMultiplier == 1) // 近圈高密度
{
computeShader.SetFloat(distanceCullStartDistID,dsitanceCullStartDisLOD0);
computeShader.SetFloat(distanceCullEndDistID,dsitanceCullEndDisLOD0);
computeShader.SetFloat(distanceCullMinimumGrassAmountlID,0.25f); // 保持一定最小密度平滑过渡
}
else // 外圈低密度 / 合并 Tile
{
computeShader.SetFloat(distanceCullStartDistID,dsitanceCullStartDisLOD1);
computeShader.SetFloat(distanceCullEndDistID,dsitanceCullEndDisLOD1);
computeShader.SetFloat(distanceCullMinimumGrassAmountlID,0);
}
//=======================================================

computeShader.SetInt(resolutionXID, tileResolution / tile.xResolutionDivisor);
computeShader.SetInt(resolutionYID, tileResolution / tile.zResolutionDivisor);
computeShader.SetBuffer(0, grassBladesBufferID, grassBladesBuffer);

float adjustedGrassSpacing = grassSpacing * tile.spaceMultiplier; // 合并 Tile 时间隔翻倍
computeShader.SetFloat(grassSpacingID, adjustedGrassSpacing);
computeShader.SetFloat(jitterStrengthID, jitterStrength);
computeShader.SetVector(tilePositionID, tile.bounds.min);

computeShader.SetVector(terrainPositionID, terrain.transform.position);
computeShader.SetTexture(0, heightMapID, terrain.terrainData.heightmapTexture);
if (terrain.terrainData.alphamapTextures.Length > 0)
{
computeShader.SetTexture(0, detailMapID, terrain.terrainData.alphamapTextures[0]);
}

computeShader.SetFloat(heightMapScaleID, terrain.terrainData.size.x);
computeShader.SetFloat(heightMapMultiplierID, terrain.terrainData.size.y);

computeShader.SetFloat(frustumCullNearOffsetID, frustumCullNearOffset);
computeShader.SetFloat(frustumCullEdgeOffsetID, frustumCullEdgeOffset);

UpdateClumpParametersBuffer();
computeShader.SetBuffer(0, clumpParametersID, clumpParametersBuffer);
computeShader.SetTexture(0, clumpTexID, clumpTexture);
computeShader.SetFloat(clumpScaleID, clumpScale);
computeShader.SetFloat(numClumpParametersID, clumpParameters.Count);

computeShader.SetTexture(0, LocalWindTexID, localWindTex);
computeShader.SetFloat(LocalWindScaleID, localWindScale);
computeShader.SetFloat(LocalWindSpeedID, localWindSpeed);
computeShader.SetFloat(LocalWindStrengthID, localWindStrength);
computeShader.SetFloat(LocalWindRotateAmountID, localWindRotateAmount);
}

其他

风力系统与动画模拟

风场 融合战神风场系统

详见 其他风场主题演讲

风的动画由滚动的2D珀林噪声驱动 [方向]。噪声被输入到一个基于正弦的函数中,该函数调制草的各种参数,如其贝塞尔控制点和朝向。

Compute Shader 中计算风力对草地运动和形态的影响

1
2
3
4
5
6
7
8
9
10
11
// 7. 局部风: 采样噪声 -> 方向 + 旋转扰动
float2 worldUV =position.xz;
float2 localWindUV =worldUV *_LocalWindScale;
localWindUV +=_Time *float2(1,0.7)* _LocalWindSpeed; // 漂移速度
float localWind =_LocalWindTex.SampleLevel(LinearRepeatSampler,localWindUV,0).r;
float localTheta =((localWind *2)-1) *3.14159;
float2 localWindDir =float2(cos(localTheta),sin(localTheta));
float2 grassSideVec =normalize(float2(-bladeFacing.y,bladeFacing.x));
float rotateBladeFromLocalWindAmount =dot(grassSideVec,localWindDir);
float localWindRotateAngle =rotateBladeFromLocalWindAmount *(3.14159 /2) *_LocalWindRotateAmount;
combinedFacingAngle += localWindRotateAngle;

Vertex Shader Sine 波动函数模拟草叶摇摆动画

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
void GetP1P2P3(
float3 p0, // 贝塞尔起点
inout float3 p3, // 末端点 (会被风影响侧向偏移)
float bend, // 主弯曲强度
float hash, // 随机种子 (决定风相位)
float windForce, // 风强度 (缩放风幅度)
out float3 p1, // 输出控制点 1
out float3 p2) // 输出控制点 2
{
// 初始控制点线性插值 (三次贝塞尔近似自然曲线)
p1 = lerp(p0, p3, 0.33);
p2 = lerp(p0, p3, 0.66);

float3 bladeDir = normalize(p3 - p0); // 主方向
float3 bezCtrlOffsetDir = normalize(cross(bladeDir, float3(0,0,1))); // 垂直于 Z 的侧向 (局部左右)

// 基础弯曲: 按 bend 沿侧向推控制点
p1 += bezCtrlOffsetDir * bend * _p1Offset;
p2 += bezCtrlOffsetDir * bend * _p2Offset;

// 风动画: 对中段(p2)与末端(p3)叠加不同相位/幅度的正弦偏移, 模拟柔性传递
float p2WindEffect = sin((_Time.y + hash * 2 * PI) * _WaveSpeed + 0.66 * 2 * PI * _SinOffsetRange) * windForce;
p2WindEffect *= 0.66 * _WaveAmplitude; // 中段幅度稍小

float p3WindEffect = sin((_Time.y + hash * 2 * PI) * _WaveSpeed + 1.0 * 2 * PI * _SinOffsetRange) * windForce + _PushTipForward * (1 - bend);
p3WindEffect *= _WaveAmplitude; // 顶端幅度最大

p2 += bezCtrlOffsetDir * p2WindEffect;
p3 += bezCtrlOffsetDir * p3WindEffect; // 顶端偏移更显著
}

草地阴影

不使用时间积累 SSAO,只使用物体 AO,原因是使用 时间积累 SSAO 需要获取草地风动的速度数据,历史顶点数据,无状态的变化难以高效重建 [ 受制于性能和内存限制,不切实际 ],同时时间积累画面效果会变斑驳。


草阴影方案:

依赖利用底层地形的“冒名顶替”系统去生成草地的阴影,我们会提升地形的顶点高度,使其与该位置草的高度相匹配,并偏移深度,以抖动模式写入阴影贴图。当我们将此与阴影过滤相结合,最终得到结果大致与草的阴影密度相匹配。

缺点 :

代理网格的离散特性,可能导致有硬边缘

之后再增加 屏幕空间阴影 Screen Space Shadow

植物散布

参考其他主题演讲

远距离草地渲染

使用艺术家提供的植物颜色纹理,避免缺乏远距离缺乏实际模型突兀变化

玩家可见性

GPU 纹理

Fizz 网格进行光线透射,以确认玩家是否可见

Future Work 后续优化

  1. 美术素材上 生成程序化草
  2. 蕨类植物等的程序化摆放
  3. 将草地分布于图块大小进一步分离,以便更容易增加草叶的距离

总结

so that’s how we rendered huge fields of grass with in our frame budget and met our art direction goals. we used compute shaders to generate per grass blade instance data that was highly artist configurable, then used indirect draw calls to get almost a hundred thousand bezier curves on screen. we supplemented these simple grass blades with procedurally placed artist assets and used very simple imposters for shadows and flower laws.

although there’s still improvements to be made we’re happy with the results we managed to achieve.

这就是我们如何在预算范围内渲染出广阔的草地,并实现我们的艺术指导目标:我们使用计算着色器生成了高度可由艺术家配置的每根草实例数据,然后通过间接绘制调用来几乎在屏幕上显示了十万条贝塞尔曲线。我们还用程序化放置的艺术资源补充了这些简单的草叶,并为阴影和花朵使用了非常简单的替身(Imposters)。
尽管仍有一些改进的空间,但我们对我们所取得的结果感到满意。