Skip to content

Commit 9ab0ca5

Browse files
committed
terrain: props can now spawn on specific layers/biomes
1 parent d50542f commit 9ab0ca5

7 files changed

Lines changed: 72 additions & 28 deletions

File tree

data/shaders/grass_populate.hlsl

Lines changed: 47 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ static const float grass_cull_radius = 1.5f;
5050
// values[0] = (cell_size, ring_radius, lod_base_in_instances, max_instances_per_lod)
5151
// values[1] = (height_min, height_max, max_slope_cos, inner_radius)
5252
// values[2] = (map_origin_x, map_origin_z, map_inv_size_x, map_inv_size_z)
53-
// values[3] = (patch_size_m, patch_coverage, patch_edge, patch_scar)
53+
// values[3] = (patch_size_m, patch_coverage, patch_edge, ground_mask bits)
5454
// a patch size of zero spreads the slot evenly, which is what this pass did before patches existed,
5555
// and a negative one inverts the field so a slot takes the ground the others left bare
5656
// every float is taken, so the rest travels in the bits of draw_index:
@@ -64,6 +64,8 @@ static const float grass_cull_radius = 1.5f;
6464
// terrain height is r32 local y bound to tex (t7), material_index bitcast is the entity y plus the
6565
// layer's seating offset, a negative offset is what pushes an instance down into the ground
6666
// biome_min arrives asfloat(is_transparent), negative disables the gate
67+
// the prop mask is rgb weights plus the dominant surface layer index in alpha, the index needs a
68+
// point tap because a bilinear one between two layers averages to a value that names a third
6769

6870
// 32-bit integer hash, takes the cell's integer world coords and returns a uniform 32-bit value
6971
// keyed off coordinates that do not move with the camera, so blade placement is stable
@@ -131,6 +133,11 @@ static const float grass_max_boost = 12.0f;
131133
// how far above its own gate the biome mask has to climb before a slot runs at full density, a
132134
// narrow band here is what keeps meadow cores thick while the mask edges still fade out
133135
static const float grass_biome_gain = 0.25f;
136+
// share of the edge fringe amount that also punches bare scars through a pocket interior, a clean
137+
// edged pocket with a pockmarked middle would read as two unrelated effects. mirrored on the cpu
138+
static const float grass_patch_scar_ratio = 0.3f;
139+
// terrain_layer_max, the dominant layer index in the mask alpha can never exceed this
140+
static const uint grass_ground_layer_max = 8u;
134141

135142
// four octaves, the fewest that still reads as an organic outline rather than a blob
136143
float grass_fbm(float2 p, uint seed)
@@ -381,7 +388,10 @@ void main_cs(uint3 dispatch_thread_id : SV_DispatchThreadID)
381388
bool patch_invert = patch_size_signed < 0.0f;
382389
float patch_coverage = saturate(buffer_pass.values[3].y);
383390
float patch_edge = saturate(buffer_pass.values[3].z);
384-
float patch_scar = saturate(buffer_pass.values[3].w);
391+
// the knob that frays the outline also breaks the interior, one is a fixed fraction of the other
392+
// so it is derived here rather than pushed, which frees the float for the ground type bits
393+
float patch_scar = patch_edge * grass_patch_scar_ratio;
394+
uint ground_mask = (uint)buffer_pass.values[3].w;
385395

386396
uint packed_index = buffer_pass.draw_index;
387397
uint lod_index = packed_index & 0xFu;
@@ -589,7 +599,7 @@ void main_cs(uint3 dispatch_thread_id : SV_DispatchThreadID)
589599
// biome mask, same uv as the heightfield, the slot picks the channel it is gated on
590600
// is_transparent carries biome_min as float bits, negative disables the gate
591601
float biome_min = asfloat(buffer_pass.is_transparent);
592-
if (biome_min >= 0.0f)
602+
if (biome_min >= 0.0f || ground_mask != 0u)
593603
{
594604
// the mask is baked at its own resolution, so it needs its own texel centre rebase
595605
uint mask_w;
@@ -600,22 +610,44 @@ void main_cs(uint3 dispatch_thread_id : SV_DispatchThreadID)
600610
terrain_world_to_normalized(world_xz),
601611
float2(mask_w, mask_h)
602612
);
603-
float4 mask = tex3.SampleLevel(samplers[sampler_bilinear_clamp], mask_uv, 0);
604-
float grass_w = mask_channel == 0u ? mask.r : (mask_channel == 1u ? mask.g : mask.b);
605-
float slope_fit = saturate((surface_normal.y - max_slope_cos) / max(1.0f - max_slope_cos, 1e-4f));
606-
if (grass_w < biome_min)
613+
614+
// ground type gate, the same bitfield the mesh placer reads. the dominant surface layer rides
615+
// in alpha as an index rather than a weight, so it takes a point tap, a bilinear one across the
616+
// boundary between two layers averages out to a value that names a third
617+
if (ground_mask != 0u)
607618
{
608-
return;
619+
float index_a = tex3.SampleLevel(samplers[sampler_point_clamp], mask_uv, 0).a;
620+
uint dominant = min(
621+
(uint)(index_a * 255.0f + 0.5f),
622+
grass_ground_layer_max - 1u
623+
);
624+
if ((ground_mask & (1u << dominant)) == 0u)
625+
{
626+
return;
627+
}
609628
}
610629

611-
// the mask decides where grass can live, the patch field above decides where it did, so the
612-
// mask is remapped to saturate just past its own gate instead of being used as the density
613-
// directly. a meadow core reading 0.6 used to throw away four blades in ten for nothing
614-
float ground = saturate((grass_w - biome_min) / grass_biome_gain) * slope_fit;
615-
float biome_roll = hash_unit(hash_mix(h0 ^ 0x27d4eb2du));
616-
if (biome_roll > ground)
630+
// a slot with the biome set to ignore still wants the ground gate above, so this is nested
631+
// rather than being the condition on the whole block
632+
if (biome_min >= 0.0f)
617633
{
618-
return;
634+
float4 mask = tex3.SampleLevel(samplers[sampler_bilinear_clamp], mask_uv, 0);
635+
float grass_w = mask_channel == 0u ? mask.r : (mask_channel == 1u ? mask.g : mask.b);
636+
float slope_fit = saturate((surface_normal.y - max_slope_cos) / max(1.0f - max_slope_cos, 1e-4f));
637+
if (grass_w < biome_min)
638+
{
639+
return;
640+
}
641+
642+
// the mask decides where grass can live, the patch field above decides where it did, so the
643+
// mask is remapped to saturate just past its own gate instead of being used as the density
644+
// directly. a meadow core reading 0.6 used to throw away four blades in ten for nothing
645+
float ground = saturate((grass_w - biome_min) / grass_biome_gain) * slope_fit;
646+
float biome_roll = hash_unit(hash_mix(h0 ^ 0x27d4eb2du));
647+
if (biome_roll > ground)
648+
{
649+
return;
650+
}
619651
}
620652
}
621653

source/editor/widgets/TerrainEditor.cpp

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1574,7 +1574,7 @@ void TerrainEditor::DrawAnalysis(Terrain* terrain)
15741574

15751575
draw_map("wear, sun, talus", terrain->GetAnalysisMapB(), size, "red is bedrock wear, green is insolation, blue is normalised height, alpha is talus scree");
15761576
ImGui::SameLine(0.0f, design::spacing_lg);
1577-
draw_map("grass, trees, rocks", terrain->GetPropMask(), size, "the biome mask, props only spawn where their channel is bright");
1577+
draw_map("grass, trees, rocks", terrain->GetPropMask(), size, "the biome mask, props only spawn where their channel is bright, alpha carries the dominant ground layer index");
15781578

15791579
if (!terrain->GetAnalysisMapA())
15801580
{

source/rendering/Renderer.h

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,9 @@ namespace spartan
151151
float max_slope_deg = 45.0f;
152152
float biome_min_weight = 0.2f;
153153
uint32_t mask_channel = 0; // which prop mask channel gates the slot, 0 grass, 1 trees, 2 rocks
154+
// ground type gate, bit i allows surface layer i, 0 allows every layer. the dominant layer
155+
// arrives in the prop mask alpha, so this needs the mask bound even with no biome channel
156+
uint32_t ground_mask = 0;
154157
float density = 1.0f;
155158
float size_min = 0.8f; // world scale on the mesh, one roll per instance inside the range
156159
float size_max = 1.2f;

source/rendering/Renderer_Passes_Geometry.cpp

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1433,7 +1433,9 @@ namespace spartan
14331433
m_pcb_pass_cpu.v[12] = patch_push;
14341434
m_pcb_pass_cpu.v[13] = state.params.patch_coverage;
14351435
m_pcb_pass_cpu.v[14] = state.params.patch_edge;
1436-
m_pcb_pass_cpu.v[15] = state.params.patch_scar;
1436+
// the scar amount is always a fixed fraction of the edge, so the shader derives it
1437+
// and this float carries the ground type bits instead, every other one is taken
1438+
m_pcb_pass_cpu.v[15] = static_cast<float>(state.params.ground_mask);
14371439
RHI_CommandList::PushConstants(m_pcb_pass_cpu);
14381440

14391441
// one cell per thread, dispatch z carries the instance index inside the cell, the

source/world/WorldHelpers.cpp

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -827,7 +827,7 @@ namespace spartan
827827
// a layer that gates on a mask channel cannot run without the mask, a layer that ignores it
828828
// does not care, which is what lets micro detail cover ground no biome claimed
829829
RHI_Texture* prop_mask = terrain->GetPropMask();
830-
if (!prop_mask && layer.mask_channel >= 0)
830+
if (!prop_mask && (layer.mask_channel >= 0 || layer.ground_mask != 0))
831831
{
832832
SP_LOG_WARNING("terrain scatter '%s': no biome mask, gpu scatter disabled", layer.name.c_str());
833833
return false;
@@ -880,6 +880,9 @@ namespace spartan
880880
// a mask channel of -1 turns the gate off, which is what detail wants, a chip belongs anywhere
881881
params.biome_min_weight = layer.mask_channel >= 0 ? layer.mask_min : -1.0f;
882882
params.mask_channel = layer.mask_channel >= 0 ? static_cast<uint32_t>(layer.mask_channel) : 0u;
883+
// the ground type gate used to stop at the mesh layers, so ticking sand on grass or pebbles
884+
// changed a value the gpu never saw
885+
params.ground_mask = layer.ground_mask;
883886
params.density = clamp(layer.density, 0.01f, 1.0f);
884887
// the gpu rolls one size per instance the same way the cpu placer does
885888
params.size_min = layer.mesh_scale * min(layer.size_min, layer.size_max);
@@ -903,7 +906,9 @@ namespace spartan
903906
params.patch_size_m = max(layer.clump_radius, 0.0f);
904907
params.patch_edge = clamp(layer.clump_raggedness, 0.0f, 1.0f);
905908
// the same knob that frays the outline breaks up the interior, a clean edged pocket with a
906-
// pockmarked middle would read as two unrelated effects
909+
// pockmarked middle would read as two unrelated effects. this is no longer pushed, the
910+
// shader derives it from the edge with the same ratio, so grass_patch_scar_ratio in
911+
// grass_populate.hlsl has to track this number. it still feeds the cpu density boost
907912
params.patch_scar = params.patch_edge * 0.3f;
908913
params.patch_invert = layer.clump_invert;
909914

source/world/components/Terrain.cpp

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7582,10 +7582,12 @@ namespace spartan
75827582
m_prop_mask_pixels[offset + 0] = static_cast<uint8_t>(grass * 255.0f + 0.5f);
75837583
m_prop_mask_pixels[offset + 1] = static_cast<uint8_t>(trees * 255.0f + 0.5f);
75847584
m_prop_mask_pixels[offset + 2] = static_cast<uint8_t>(rock * 255.0f + 0.5f);
7585-
m_prop_mask_pixels[offset + 3] = 255;
7586-
// which surface layer won this point, it stays off the texture so the preview and the
7587-
// grass populate pass keep reading an opaque rgb mask
7588-
m_layer_dominant[i] = static_cast<uint8_t>(dominant_layer);
7585+
// which surface layer won this point. the mesh placer reads it off the cpu copy below,
7586+
// the gpu scatter pass reads it out of alpha, which is what lets a ground type gate
7587+
// apply to grass and detail instead of only to mesh layers. it is an index and not a
7588+
// weight, so anything sampling it has to use a point tap
7589+
m_prop_mask_pixels[offset + 3] = static_cast<uint8_t>(dominant_layer);
7590+
m_layer_dominant[i] = static_cast<uint8_t>(dominant_layer);
75897591
}
75907592
};
75917593
ThreadPool::ParallelLoop(bake, static_cast<uint32_t>(cell_count));

0 commit comments

Comments
 (0)