diff --git a/changes/render3d-blade-density.md b/changes/render3d-blade-density.md new file mode 100644 index 00000000..83d3e461 --- /dev/null +++ b/changes/render3d-blade-density.md @@ -0,0 +1,9 @@ +bump: minor +type: feature +**`ludic.render3d`: the GPU blades grow where a painted density says.** `grass_density_layer(l)` names a layer of +the ground densities (`ground_density_open`: the same 2 m texel, 64 m tiles, bake and dev fallback) as the +blades'; `grass_cull.comp` then keeps a blade by that density, bilinear, where it used to apply its rules (the +slope, the water's margin, the snow line, the photograph). Without one, nothing changes. The cull reads a +window of 5 x 5 density tiles round the camera (160^2 bytes, a host-visible storage buffer, re-filled from +the CPU tile cache when the camera crosses a 64 m tile), never the whole map. `grass_rule_at(x, z)` is the +rules on the CPU, for a migration that paints them; `grass_density_texels()` the side a density PNG has. diff --git a/packages/ludic.render3d/env.ludic b/packages/ludic.render3d/env.ludic index 3265fd1a..5d56490e 100644 --- a/packages/ludic.render3d/env.ludic +++ b/packages/ludic.render3d/env.ludic @@ -856,6 +856,11 @@ export state Render3dState { gd_slot_of: words = null gd_hand: int = 0 gd_trample_cb: fn(float, float) -> float = null + gb_layer: int = -1 # the densities' layer the blades grow by; -1: the rules (grass_density.ludic) + gb_buf: int = 0 # its window round the camera, on the GPU + gb_win: words = null # ... and the window's bytes, four texels a word, made once + gb_tx: int = -1000000 # the window's first texel in the map, x and z (-1000000: not filled) + gb_tz: int = 0 tt_dir: string = "" # terrain_tiles_to: where a map's tiles are written ("" keeps the whole copies) tt_key: string = "" tt_file: pointer = null # the tiles' file, open for reading (terrain_tiles.ludic) diff --git a/packages/ludic.render3d/gpu_vk.ludic b/packages/ludic.render3d/gpu_vk.ludic index bcd4de09..2339ab4f 100644 --- a/packages/ludic.render3d/gpu_vk.ludic +++ b/packages/ludic.render3d/gpu_vk.ludic @@ -814,8 +814,8 @@ function gvk_startup_state(render3d_st: mut Render3dState) -> void { render3d_st.gvk_pc_pass = new []int; render3d_st.gvk_pc_pipe = new []long render3d_st.gvk_pc_last = words(4096) for i in 0 .. 4096 { render3d_st.gvk_pc_last[i] = -1 } - render3d_st.gg_rb = words(8); render3d_st.gg_cb = words(1); render3d_st.gg_bufs = words(3) - render3d_st.gg_texs = words(7); render3d_st.gg_pr = words(72) + render3d_st.gg_rb = words(8); render3d_st.gg_cb = words(1); render3d_st.gg_bufs = words(4) + render3d_st.gg_texs = words(7); render3d_st.gg_pr = words(76) if render3d_st.gpu_unit_2d == null { render3d_st.gpu_unit_2d = words(32) for i in 0 .. 32 { render3d_st.gpu_unit_2d[i] = -1 } diff --git a/packages/ludic.render3d/grass_density.ludic b/packages/ludic.render3d/grass_density.ludic new file mode 100644 index 00000000..d25e6e3a --- /dev/null +++ b/packages/ludic.render3d/grass_density.ludic @@ -0,0 +1,100 @@ +# grass_density.ludic — the blades grow where the map's painted density says, when it has one: a layer of +# the ground densities (ground_density.ludic; a PNG like any ground layer's, the same 2 m texel and 64 m +# tiles), named by grass_density_layer. Without one grass_cull.comp keeps its rules (grass_rule_at here). +# +# The cull decides every blade on the GPU, so the density must be there too - but not all of it: 4096^2 +# bytes is 16 MB, and the blades stand within grass_reach (70 m at most). The GPU holds a window of +# GB_TILES x GB_TILES density tiles round the camera's (5 x 64 m: 160^2 bytes, 25 KB, twice while a re-fill +# swaps), re-filled from the CPU tile cache when the camera crosses into another tile. The camera stays in +# the middle tile, so the window reaches at least 128 m every way. A host-visible storage buffer, not a +# texture: filling a texture waits for the GPU (gpu_tex_fill), a buffer's re-fill swaps under the frame +# still reading it (gvk_buf_reserve), and the cull does its own bilinear in four reads. + +const GB_TILES: int = 5 +const GB_NONE: int = -1000000 + +# The layer of the ground densities the blades grow by (-1: none, the rules). Call it after +# ground_density_open, with the index the blades' PNG had in its list; closing the densities forgets it. +export function grass_density_layer(render3d_st: mut Render3dState, l: int) -> void { + render3d_st.gb_layer = l + render3d_st.gb_tx = GB_NONE +} + +# the side, in texels, a density PNG must have for this renderer (as every ground layer's) +export function grass_density_texels() -> int { return TERRAIN_RES / TT_TEX * GD_SIDE } + +# whether the blades read a density: a layer named, and the densities open with it +function gb_on(render3d_st: Render3dState) -> bool { + let l = render3d_st.gb_layer + if l < 0 or render3d_st.gd_file == null or render3d_st.gb_win == null or l >= render3d_st.gd_layers { return false } + return render3d_st.gd_ch[l] > 0 +} + +# the densities' texel in metres +function gb_texel(render3d_st: Render3dState) -> float { return 2.0 * float(render3d_st.TERRAIN_HALF) / float(render3d_st.gd_n * GD_SIDE) } + +# once, with the cull's buffers: the window's bytes and its buffer, at its full size (zeros) +@alloc_ok("start-up: the density window's bytes and buffer, once") +function gb_init(render3d_st: mut Render3dState) -> void { + let side = GB_TILES * GD_SIDE + render3d_st.gb_win = words(side * side / 4) + for i in 0 .. side * side / 4 { render3d_st.gb_win[i] = 0 } + render3d_st.gb_buf = gpu_buffer_new(render3d_st) + gpu_buffer_upload(render3d_st, render3d_st.gb_buf, side * side, data_of(render3d_st.gb_win), GPU_DYNAMIC) +} + +# before the cull: the window re-filled and sent when the camera is no longer in its middle tile +function gb_frame(render3d_st: mut Render3dState) -> void { + if not gb_on(render3d_st) { return } + let texel = gb_texel(render3d_st) + let tm = texel * float(GD_SIDE) + let half = float(render3d_st.TERRAIN_HALF) + let ctx = int(Math.floor((render3d_st.cam_pos[0] - render3d_st.ter_ox + half) / tm)) + let ctz = int(Math.floor((render3d_st.cam_pos[2] - render3d_st.ter_oz + half) / tm)) + let tx = (ctx - GB_TILES / 2) * GD_SIDE + let tz = (ctz - GB_TILES / 2) * GD_SIDE + if tx == render3d_st.gb_tx and tz == render3d_st.gb_tz { return } + for j in 0 .. GB_TILES { + for i in 0 .. GB_TILES { gb_fill_tile(render3d_st, i, j, ctx - GB_TILES / 2 + i, ctz - GB_TILES / 2 + j) } + } + let side = GB_TILES * GD_SIDE + gpu_buffer_upload(render3d_st, render3d_st.gb_buf, side * side, data_of(render3d_st.gb_win), GPU_DYNAMIC) + render3d_st.gb_tx = tx + render3d_st.gb_tz = tz +} + +# window tile (i, j) from the map's tile (mx, mz): its density bytes, four to a word; zeros off the map +function gb_fill_tile(render3d_st: mut Render3dState, i: int, j: int, mx: int, mz: int) -> void { + let l = render3d_st.gb_layer + let n = render3d_st.gd_n + let wq = GB_TILES * GD_SIDE / 4 + let inside = mx >= 0 and mz >= 0 and mx < n and mz < n + var at = 0 + var ch = 1 + if inside { + at = gd_slot(render3d_st, l, mz * n + mx) * GD_SIDE * GD_SIDE * 2 + ch = render3d_st.gd_ch[l] + } + let pool = render3d_st.gd_pool + for r in 0 .. GD_SIDE { + for q in 0 .. GD_SIDE / 4 { + var w = 0 + if inside { + let o = at + (r * GD_SIDE + q * 4) * ch + w = pool[o] | (pool[o + ch] << 8) | (pool[o + 2 * ch] << 16) | (pool[o + 3 * ch] << 24) + } + render3d_st.gb_win[(j * GD_SIDE + r) * wq + i * GD_SIDE / 4 + q] = w + } + } +} + +# grass_cull.comp's Params word 72: the window's world corner, its texel and side; a side of 0 is the rules +function gb_params(render3d_st: Render3dState, pr: words) -> void { + if not gb_on(render3d_st) or render3d_st.gb_tx == GB_NONE { return } + let texel = gb_texel(render3d_st) + let half = float(render3d_st.TERRAIN_HALF) + pr[72] = float_bits(render3d_st.ter_ox - half + float(render3d_st.gb_tx) * texel) + pr[73] = float_bits(render3d_st.ter_oz - half + float(render3d_st.gb_tz) * texel) + pr[74] = float_bits(texel) + pr[75] = float_bits(float(GB_TILES * GD_SIDE)) +} diff --git a/packages/ludic.render3d/grass_gpu.ludic b/packages/ludic.render3d/grass_gpu.ludic index 9e85da0c..853caa44 100644 --- a/packages/ludic.render3d/grass_gpu.ludic +++ b/packages/ludic.render3d/grass_gpu.ludic @@ -37,7 +37,7 @@ function gg_init__t(render3d_st: mut Render3dState) -> void { if not gpu_has_compute(render3d_st) or render3d_st.grass_prog == 0 { return } if r3d_env_has(render3d_st, "R3D_GRASS_GPU") and r3d_env(render3d_st, "R3D_GRASS_GPU") == "0" { return } render3d_st.gg_reset = gpu_compute(render3d_st, "grass_reset", 1) - render3d_st.gg_cull = gpu_compute_tex(render3d_st, "grass_cull", 3, 7) + render3d_st.gg_cull = gpu_compute_tex(render3d_st, "grass_cull", 4, 7) render3d_st.gg_prog = r3d_program(render3d_st, "grass_inst.vert", "model.frag", "#define FOLIAGE\n#define BLADE\n#define GBLADE\n#define GINST\n") if render3d_st.gg_reset == 0 or render3d_st.gg_cull == 0 or render3d_st.gg_prog == 0 { return } render3d_st.gg_tiles_buf = new []int @@ -57,6 +57,7 @@ function gg_init__t(render3d_st: mut Render3dState) -> void { render3d_st.gg_cmds = gpu_buffer_new(render3d_st) gpu_buffer_upload(render3d_st, render3d_st.gg_cmds, GG_BANDS * 20, data_of(rec), GPU_DYNAMIC) gpu_buffer_gpu_owned(render3d_st, render3d_st.gg_cmds) + gb_init(render3d_st) render3d_st.gg_on = true print("r3d: grass: blades are culled on the GPU") } @@ -132,13 +133,14 @@ function gg_cull_frame(render3d_st: mut Render3dState) -> void { # the tile list alternates buffers by frame, so the frame still on the GPU keeps its own let tb = render3d_st.gg_tiles_buf[render3d_st.r3d_test_frame % 2] gpu_buffer_upload(render3d_st, tb, render3d_st.gg_n * 16, data_of(render3d_st.gg_tv), GPU_DYNAMIC) + gb_frame(render3d_st) let pr = gg_params(render3d_st) let bufs = render3d_st.gg_bufs - bufs[0] = tb; bufs[1] = render3d_st.gg_out; bufs[2] = render3d_st.gg_cmds + bufs[0] = tb; bufs[1] = render3d_st.gg_out; bufs[2] = render3d_st.gg_cmds; bufs[3] = render3d_st.gb_buf let texs = render3d_st.gg_texs texs[0] = render3d_st.ter_height_tex; texs[1] = render3d_st.ter_ortho_tex; texs[2] = render3d_st.ter_normal_tex gg_page_texs(render3d_st, texs) - gpu_dispatch_tex(render3d_st, render3d_st.gg_cull, data_of(pr), 288, bufs, texs, (render3d_st.gg_max + 63) / 64, render3d_st.gg_n) + gpu_dispatch_tex(render3d_st, render3d_st.gg_cull, data_of(pr), 304, bufs, texs, (render3d_st.gg_max + 63) / 64, render3d_st.gg_n) } # the page pool's four, after the three whole-map ones (the stand-ins while paging is off) @@ -148,10 +150,10 @@ function gg_page_texs(render3d_st: mut Render3dState, texs: words) -> void { if render3d_st.tp_on { texs[3] = render3d_st.tp_page_tex; texs[4] = render3d_st.tp_h_tex; texs[5] = render3d_st.tp_n_tex; texs[6] = render3d_st.tp_o_tex } } -# grass_cull.comp's Params, std140: 18 vec4s, into the block made once with the device +# grass_cull.comp's Params, std140: 19 vec4s, into the block made once with the device function gg_params(render3d_st: mut Render3dState) -> words { let pr = render3d_st.gg_pr - for i in 0 .. 72 { pr[i] = 0 } + for i in 0 .. 76 { pr[i] = 0 } if render3d_st.cam_planes != null { for i in 0 .. 16 { pr[i] = float_bits(render3d_st.cam_planes[i]) } } pr[16] = float_bits(render3d_st.cam_pos[0]); pr[17] = float_bits(render3d_st.cam_pos[1]); pr[18] = float_bits(render3d_st.cam_pos[2]); pr[19] = float_bits(grass_reach(render3d_st)) var ph = render3d_st.post_h @@ -171,6 +173,7 @@ function gg_params(render3d_st: mut Render3dState) -> words { # the page pool (terrain_pages.ludic): on in lev.w, and its dims as the terrain's shaders take them if render3d_st.tp_on { pr[31] = float_bits(1.0) } pr[48] = float_bits(float(render3d_st.tt_n)); pr[49] = float_bits(float(TT_TEX)); pr[50] = float_bits(float(render3d_st.tt_oside)); pr[51] = float_bits(float(TERRAIN_RES)) + gb_params(render3d_st, pr) return pr } diff --git a/packages/ludic.render3d/grass_rule.ludic b/packages/ludic.render3d/grass_rule.ludic new file mode 100644 index 00000000..55b2091c --- /dev/null +++ b/packages/ludic.render3d/grass_rule.ludic @@ -0,0 +1,53 @@ +# grass_rule.ludic — grass_cull.comp's bladeRules on the CPU: whether the ground at (x, z) grows blades, 0..1, +# for the migration that paints it into a map's blades density (keep the two in step). Every term here varies +# slower than a 2 m texel - the slope, the water's margin, the snow line, the photograph - so a painted texel +# holds it; what varies within one (each blade's hashes, the tussocks, the colour field) stays in the shader. + +# the ground's keep at (x, z), under the kind in use and the map's water and snow +export function grass_rule_at(render3d_st: mut Render3dState, x: float, z: float) -> float { + let k = grass_kind(render3d_st) + let e = terrain_texel(render3d_st) + let y = terrain_height(render3d_st, x, z) + let gx = (terrain_height(render3d_st, x + e, z) - terrain_height(render3d_st, x - e, z)) / (2.0 * e) + let gz = (terrain_height(render3d_st, x, z + e) - terrain_height(render3d_st, x, z - e)) / (2.0 * e) + let ny = 1.0 / Math.sqrt(1.0 + gx * gx + gz * gz) + let snow = render3d_st.ter_snow_line + var ok = (1.0 - gr_smooth(k.slope_lo, k.slope_hi, 1.0 - ny)) * gr_smooth(0.0, 0.6, y - gr_water(render3d_st, x, z) - 0.15) + ok = ok * gr_smooth(snow - k.snow_in, snow - k.snow_out, y) + if ok <= 0.0 or render3d_st.ter_ortho_tex == 0 { return ok } + # the photograph varies inside a texel: its term averaged over four points of it + var o = 0.0 + for s in 0 .. 4 { + let c = terrain_ortho(render3d_st, x + (float(s % 2) - 0.5) * e * 0.5, z + (float(s / 2) - 0.5) * e * 0.5) + let gb = gr_linear((c >> 8) & 255) - gr_linear(c & 255) + o = o + k.ortho_floor + k.ortho_gain * gr_smooth(0.0, k.ortho_green, gb) + } + return ok * o * 0.25 +} + +# the water line there: the sea's, or the carved lake's inside its ellipse (as gg_params hands them over) +function gr_water(render3d_st: Render3dState, x: float, z: float) -> float { + var lake = -100000.0 + if render3d_st.ter_lake_ex != 0.0 { lake = render3d_st.ter_lake_level } + var wl = lake + if render3d_st.ter_sea_set { wl = render3d_st.ter_sea_level } + if render3d_st.ter_lake_ex > 0.0 { + let qx = (x - render3d_st.ter_lake_cx) / render3d_st.ter_lake_ex + let qz = (z - render3d_st.ter_lake_cz) / render3d_st.ter_lake_ez + if qx * qx + qz * qz < 1.0 { wl = Math.max(wl, lake) } + } + return wl +} + +# GLSL's smoothstep, edges either way round as the shader's snow line has them +function gr_smooth(e0: float, e1: float, v: float) -> float { + let t = Math.clamp((v - e0) / (e1 - e0), 0.0, 1.0) + return t * t * (3.0 - 2.0 * t) +} + +# an sRGB byte as the shader reads it from the photograph's sRGB texture: linear +function gr_linear(c: int) -> float { + let v = float(c) / 255.0 + if v <= 0.04045 { return v / 12.92 } + return Math.pow((v + 0.055) / 1.055, 2.4) +} diff --git a/packages/ludic.render3d/ground_density.ludic b/packages/ludic.render3d/ground_density.ludic index 0672a2f9..e03cc013 100644 --- a/packages/ludic.render3d/ground_density.ludic +++ b/packages/ludic.render3d/ground_density.ludic @@ -68,6 +68,7 @@ function gd_cache_reset(render3d_st: mut Render3dState, tiles: int) -> void { export function ground_density_close(render3d_st: mut Render3dState) -> void { if render3d_st.gd_file != null { file_close(render3d_st.gd_file); render3d_st.gd_file = null } + render3d_st.gb_layer = -1 # a layer index means nothing in the next map's file (grass_density_layer) } # the slot holding layer l's tile t, read in when it is not (a clock, as the terrain's tiles) diff --git a/packages/ludic.render3d/r3d.ludic b/packages/ludic.render3d/r3d.ludic index 75b97723..f4e52a9a 100644 --- a/packages/ludic.render3d/r3d.ludic +++ b/packages/ludic.render3d/r3d.ludic @@ -60,6 +60,8 @@ import "grass_kind.ludic" import "grass_bind.ludic" import "grass.ludic" import "grass_gpu.ludic" +import "grass_density.ludic" +import "grass_rule.ludic" import "water.ludic" import "streamline.ludic" import "render.ludic" diff --git a/packages/ludic.render3d/shaders/grass_cull.comp b/packages/ludic.render3d/shaders/grass_cull.comp index e533d3c2..442882bb 100644 --- a/packages/ludic.render3d/shaders/grass_cull.comp +++ b/packages/ludic.render3d/shaders/grass_cull.comp @@ -27,20 +27,22 @@ layout(set = 0, binding = 0) uniform Params { vec4 gk_clump; // clump_lo, clump_hi, arch, arch_var vec4 gk_ground; // slope_lo, slope_hi, snow_in, snow_out vec4 gk_ortho; // ortho_floor, ortho_gain, ortho_green, cell (m) + vec4 bd; // the painted density's window (grass_density.ludic): world x/z of its corner, m a texel, texels a side (0: the rules) } pr; layout(set = 0, binding = 1) readonly buffer Tiles { vec4 tiles[]; }; // corner x/z, indices per cell, cells per side layout(set = 0, binding = 2) buffer Out { vec4 outv[]; }; // 4 per blade layout(set = 0, binding = 3) buffer Cmds { uint cmds[]; }; // VkDrawIndexedIndirectCommand per band -layout(set = 0, binding = 4) uniform sampler2D u_height; // the height (terrain's u_ts_height) -layout(set = 0, binding = 5) uniform sampler2D u_ortho; // the photograph -layout(set = 0, binding = 6) uniform sampler2D u_ter_normal; // the normal: x and z, y rebuilt +layout(set = 0, binding = 4) readonly buffer Dens { uint dens[]; }; // the density's window, four texels a word +layout(set = 0, binding = 5) uniform sampler2D u_height; // the height (terrain's u_ts_height) +layout(set = 0, binding = 6) uniform sampler2D u_ortho; // the photograph +layout(set = 0, binding = 7) uniform sampler2D u_ter_normal; // the normal: x and z, y rebuilt // the page pool (terrain_pages.ludic): while it is on, the three above are the coarse whole-map level // and the tiles round the camera are layers of these, addressed through the page table -layout(set = 0, binding = 7) uniform sampler2D u_tp_page; // slot + 1 per tile, 0 = coarse -layout(set = 0, binding = 8) uniform sampler2DArray u_tp_h; // (T + 2)^2 a layer, a texel of border -layout(set = 0, binding = 9) uniform sampler2DArray u_tp_nrm; -layout(set = 0, binding = 10) uniform sampler2DArray u_tp_ortho; // (S + 2)^2 a layer +layout(set = 0, binding = 8) uniform sampler2D u_tp_page; // slot + 1 per tile, 0 = coarse +layout(set = 0, binding = 9) uniform sampler2DArray u_tp_h; // (T + 2)^2 a layer, a texel of border +layout(set = 0, binding = 10) uniform sampler2DArray u_tp_nrm; +layout(set = 0, binding = 11) uniform sampler2DArray u_tp_ortho; // (S + 2)^2 a layer #define CELL pr.gk_ortho.w // the kind's cell (m) @@ -91,6 +93,34 @@ float heightSmooth(vec2 uv) { return (groundH(vec2(o0.x, o0.y)) * s0.x + groundH(vec2(o1.x, o0.y)) * s1.x) * s0.y + (groundH(vec2(o0.x, o1.y)) * s0.x + groundH(vec2(o1.x, o1.y)) * s1.x) * s1.y; } +// the painted density, 0..1, bilinear over texel centres as ground_density_at reads it on the CPU +float densTexel(ivec2 t, int side) { + t = clamp(t, ivec2(0), ivec2(side - 1)); + uint k = uint(t.y * side + t.x); + return float((dens[k >> 2u] >> ((k & 3u) * 8u)) & 0xFFu) * (1.0 / 255.0); +} +float bladeDensity(vec2 xz) { + int side = int(pr.bd.w + 0.5); + vec2 f = (xz - pr.bd.xy) / pr.bd.z - 0.5; + vec2 i0 = floor(f); + vec2 a = f - i0; + ivec2 i = ivec2(i0); + float lo = mix(densTexel(i, side), densTexel(i + ivec2(1, 0), side), a.x); + float hi = mix(densTexel(i + ivec2(0, 1), side), densTexel(i + ivec2(1, 1), side), a.x); + return mix(lo, hi, a.y); +} +// today's rules, where no density is painted: the slope, the water's margin, the snow line, the photograph +// (grass_rule_at in grass_density.ludic is this on the CPU, for the migration that paints them: keep in step) +float bladeRules(vec2 xz, vec2 huv, float y, vec3 gn) { + float wl = pr.lev.y; + if (pr.lake.z > 0.0) { vec2 q = (xz - pr.lake.xy) / pr.lake.zw; if (dot(q, q) < 1.0) wl = max(wl, pr.lev.x); } + float ok = (1.0 - smoothstep(pr.gk_ground.x, pr.gk_ground.y, 1.0 - gn.y)) * smoothstep(0.0, 0.6, y - wl - 0.15) * smoothstep(pr.dens.w - pr.gk_ground.z, pr.dens.w - pr.gk_ground.w, y); + if (pr.lev.z > 0.5) { + vec3 oc = groundO(huv); + ok *= pr.gk_ortho.x + pr.gk_ortho.y * smoothstep(0.0, pr.gk_ortho.z, oc.g - oc.b); + } + return ok; +} // grass.vert's bladeField: the region, the patchiness, the dry patches and the tussocks' shade vec3 bladeField(vec2 xz, float y) { float n = fbm(xz * 0.0018 + 4.0, 3) * 0.5 + 0.5; @@ -136,13 +166,8 @@ void main() { vec2 gxz = groundN(huv); vec3 gn = vec3(gxz.x, sqrt(max(1.0 - dot(gxz, gxz), 0.0)), gxz.y); float h3 = bladeHash(ci, j, 2), h4 = bladeHash(ci, j, 3); - float wl = pr.lev.y; - if (pr.lake.z > 0.0) { vec2 q = (xz - pr.lake.xy) / pr.lake.zw; if (dot(q, q) < 1.0) wl = max(wl, pr.lev.x); } - float ok = (1.0 - smoothstep(pr.gk_ground.x, pr.gk_ground.y, 1.0 - gn.y)) * smoothstep(0.0, 0.6, ht.r - wl - 0.15) * smoothstep(pr.dens.w - pr.gk_ground.z, pr.dens.w - pr.gk_ground.w, ht.r); - if (pr.lev.z > 0.5) { - vec3 oc = groundO(huv); - ok *= pr.gk_ortho.x + pr.gk_ortho.y * smoothstep(0.0, pr.gk_ortho.z, oc.g - oc.b); - } + // where it grows: the map's painted density when it has one, else the rules; the hashes stay procedural + float ok = pr.bd.w > 0.5 ? bladeDensity(xz) : bladeRules(xz, huv, ht.r, gn); if (h4 > ok) return; float h = heightSmooth(huv); float seed = hv.x * 0.7 + hv.y * 0.3; diff --git a/packages/ludic.render3d/shaders/spv/grass_cull.comp.spv b/packages/ludic.render3d/shaders/spv/grass_cull.comp.spv index 26165a32..63deb129 100644 Binary files a/packages/ludic.render3d/shaders/spv/grass_cull.comp.spv and b/packages/ludic.render3d/shaders/spv/grass_cull.comp.spv differ