render3d: a grass kind - the GPU blades' per-kind numbers are a GrassKind record the game hands over (grass_kind_set), not constants

GrassKind (grass_kind.ludic) carries what grass.ludic, grass_gpu.ludic, grass.vert, grass_cull.comp and
model.frag's BLADE path held as constants: the cell, s0 / d0 / radius, the row bands and rows, the blade
profile (neck, root, swell, tip, bend), the heights, clumps, widths and arch, where it grows (slope, snow,
the photograph's test), the root / tip / gone-to-seed colours, the far tufts, the root occlusion, the
roughness, the sheen and the wind. Its defaults are those constants exactly, so a game that sets nothing
draws as before. The shaders read them as u_gk_* (the cull as five more Params vec4s, 288 bytes);
grass_reset.comp writes each band's index count so a kind of other rows reaches the draw in order.
grass_quality holds the kind to a settings tier (never denser, never farther); R3D_GRASS_* still win.
grass_profile_w / _bend are the one profile, for the meshes and a game's preview. grass.mesh takes only
the cell: the rest of it is an older copy that already differs from grass.vert, left as it draws.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-29 19:03:33 +03:00
parent d9a73d63bd
commit e908672034
25 changed files with 335 additions and 75 deletions

View file

@ -10,7 +10,7 @@
# draw count down — the cells and their hashes are the same in every tile size.
# ============================================================================
const GRASS_CELL: int = 4
# The cell (GRASS_CELL until the kinds) is the kind's now: render3d_st.grass_cell (grass_kind.ludic).
# A photographed blade, as an atlas of straightened blades side by side (the game sets
# this; the renderer does not name a game asset). 0 = the procedural gradient, which is
# what this was for a year: a two-tone ramp with a hard edge, and every blade in the
@ -39,6 +39,7 @@ function grass_mesh_live(render3d_st: Render3dState) -> bool { return render3d_s
# a blade: `rows` rows of 2 vertices (x across, y along, z bend), attribute 2 = uv
function grass_blade_mesh(render3d_st: mut Render3dState, rows: int) -> Mesh {
let gk = grass_kind(render3d_st)
let m = gpu_mesh_new(render3d_st)
let v = gl_floats(rows * 2 * 5)
var k = 0
@ -53,14 +54,12 @@ function grass_blade_mesh(render3d_st: mut Render3dState, rows: int) -> Mesh {
# so the point is a point and not a cut-off stub, but not so low that the last quad is
# degenerate.
#
# grass.mesh carries A COPY of these two lines for the mesh-shader path; change both or
# the Windows blades stop matching the ones everywhere else.
# The numbers are the kind's (grass_profile_w / _bend). grass.mesh carries A COPY of the
# default profile for the mesh-shader path; change both or the Windows blades stop matching.
for r in 0 .. rows {
let t = float(r) / float(rows - 1)
let grow = Math.min(t / 0.22, 1.0)
let wide = (0.50 + 0.33 * grow) * (1.0 - t * (t * t))
let taper = Math.max(wide, 0.05)
let bend = t * t * 0.52
let taper = grass_profile_w(gk, t)
let bend = grass_profile_bend(gk, t)
for sd in 0 .. 2 {
var x = -0.5
if sd == 1 { x = 0.5 }
@ -108,12 +107,7 @@ function grass_init__t(render3d_st: mut Render3dState) -> void {
}
render3d_st.grass_prog = r3d_program(render3d_st, "grass.vert", "model.frag", defs)
if render3d_st.grass_merge and gpu_has_mesh(render3d_st) { render3d_st.grass_mesh_prog = r3d_program(render3d_st, "grass.mesh", "model.frag", "#define FOLIAGE\n#define BLADE\n#define MESH\n") }
# five rows, four quads: the arch above needs somewhere to bend, and at four rows a
# blade that leans over is three straight segments and shows every join
render3d_st.grass_mesh = grass_blade_mesh(render3d_st, 5)
render3d_st.grass_mesh3 = grass_blade_mesh(render3d_st, 3)
render3d_st.grass_mesh2 = grass_blade_mesh(render3d_st, 2)
render3d_st.grass_wind = 2.4
grass_meshes_build(render3d_st)
# Matched to the blade's width: a 1 cm blade at 0.11 m spacing covers a third of what a
# 2.8 cm blade did, and the meadow goes bare. The game's graphics settings override this
# (gfx_grass_spacing), but only once game_init has run - a plain headless render never
@ -122,17 +116,30 @@ function grass_init__t(render3d_st: mut Render3dState) -> void {
# because this line, not the settings, was deciding.
# The spacing doubles every 18 m and the blades stop at 70 m: at 45 m and 1600 m a 1 cm blade
# was under a pixel from 20 m on, costing a full blade's vertices to shimmer (5 ms of 9.7 on GL).
render3d_st.grass_s0 = 0.066
render3d_st.grass_d0 = 18.0
render3d_st.grass_radius = 70.0
# The spacing, its doubling distance and the reach are the kind's (0.066, 18 and 70 by default),
# held to the settings' tier (grass_quality) and then to R3D_GRASS_S0 / _D0 / _R (grass_derive).
grass_derive(render3d_st)
if r3d_env_has(render3d_st, "R3D_NOBLADES") { render3d_st.grass_on = false }
if r3d_env_has(render3d_st, "R3D_GRASS_R") { render3d_st.grass_radius = float(Text.to_int(r3d_env(render3d_st, "R3D_GRASS_R"))) }
if r3d_env_has(render3d_st, "R3D_GRASS_S0") { render3d_st.grass_s0 = float(Text.to_int(r3d_env(render3d_st, "R3D_GRASS_S0"))) / 1000.0 }
if r3d_env_has(render3d_st, "R3D_GRASS_D0") { render3d_st.grass_d0 = float(Text.to_int(r3d_env(render3d_st, "R3D_GRASS_D0"))) }
if r3d_env_has(render3d_st, "R3D_GRASS_DBG") { render3d_st.grass_dbg = Text.to_int(r3d_env(render3d_st, "R3D_GRASS_DBG")) }
gg_init(render3d_st)
}
# The three blades, at the kind's rows: five, four quads, near (the arch needs somewhere to bend, and
# at four rows a blade that leans over is three straight segments); three; and one quad far out.
# Made again only when a kind with other rows or another profile is set (grass_kind_set).
@alloc_ok("start-up, and a kind of another shape set at a world build: the blade meshes are made once")
function grass_meshes_build(render3d_st: mut Render3dState) -> void {
if render3d_st.grass_prog == 0 { return }
let k = grass_kind(render3d_st)
mesh_free(render3d_st, render3d_st.grass_mesh)
mesh_free(render3d_st, render3d_st.grass_mesh3)
mesh_free(render3d_st, render3d_st.grass_mesh2)
render3d_st.grass_mesh = grass_blade_mesh(render3d_st, k.rows_near)
render3d_st.grass_mesh3 = grass_blade_mesh(render3d_st, k.rows_mid)
render3d_st.grass_mesh2 = grass_blade_mesh(render3d_st, k.rows_far)
if render3d_st.gg_on { gg_meshes_build(render3d_st) }
}
# indices per 16 m cell that could exist at distance d (the count the shader computes)
# Under a fog wall the blades end at three quarters of it. A blade's tip stands against ground
# much farther off, and a dark blade 85% fogged read as a dark band against fully fogged ground
@ -146,14 +153,14 @@ function grass_reach(render3d_st: Render3dState) -> float {
function grass_count_at(render3d_st: Render3dState, d: float) -> int {
let spacing = render3d_st.grass_s0 * (1.0 + d / render3d_st.grass_d0)
let n = float(GRASS_CELL * GRASS_CELL) / (spacing * spacing)
let n = float(render3d_st.grass_cell * render3d_st.grass_cell) / (spacing * spacing)
return int(n) + 1
}
# one tile size over one distance band
function grass_tiles(render3d_st: mut Render3dState, size: int, d_min: float, d_max: float, blade: Mesh) -> void {
let p = render3d_st.grass_prog
let cells = size / GRASS_CELL
let cells = size / render3d_st.grass_cell
if d_min >= grass_reach(render3d_st) { return }
if render3d_st.grass_merge {
render3d_st.grass_band_start[render3d_st.grass_band_n] = render3d_st.grass_n; render3d_st.grass_band_cells[render3d_st.grass_band_n] = cells
@ -231,7 +238,8 @@ function grass_bind(render3d_st: mut Render3dState, p: int) -> void {
u_mat4(render3d_st, gpu_uniform(render3d_st, p, "u_view"), render3d_st.cam_view)
u_mat4(render3d_st, gpu_uniform(render3d_st, p, "u_proj"), render3d_st.cam_proj)
u_mat4(render3d_st, gpu_uniform(render3d_st, p, "u_vp"), render3d_st.cam_vp_clean)
u_f(render3d_st, gpu_uniform(render3d_st, p, "u_wind"), render3d_st.grass_wind)
u_f(render3d_st, gpu_uniform(render3d_st, p, "u_wind"), grass_kind(render3d_st).wind)
grass_bind_kind(render3d_st, p)
# copied into a local first: a global reaching a uniform call is the codegen fault
# CLAUDE.md records against u_wade and u_flutter, and it costs a day every time
let btex = render3d_st.grass_blade_tex
@ -274,17 +282,20 @@ function grass_bind(render3d_st: mut Render3dState, p: int) -> void {
# caught the sun, and a white blade of grass is the one thing grass is never. Measured:
# at 0.15, 0.70% of a near-ground frame was over 210 of 255; at 0.0 it is 0.05%. A blade
# does have a faint sheen, so this is small rather than nothing - the foliage layers have
# used 0.05 all along and never showed the fault.
u_f(render3d_st, gpu_uniform(render3d_st, p, "u_spec_scale"), 0.008)
# used 0.05 all along and never showed the fault. The kind's `spec` (0.008).
u_f(render3d_st, gpu_uniform(render3d_st, p, "u_spec_scale"), grass_kind(render3d_st).spec)
}
# the per-vertex path's tiles, in four bands
function grass_bands(render3d_st: mut Render3dState) -> void {
# four bands: the tile grows and the blade loses rows with distance (a band past the radius is
# skipped); a tile asks for what its NEAREST point needs, so a wide tile far out wastes the rest
grass_tiles(render3d_st, 4, 0.0, 6.0, render3d_st.grass_mesh)
grass_tiles(render3d_st, 4, 6.0, 16.0, render3d_st.grass_mesh3)
grass_tiles(render3d_st, 8, 16.0, 40.0, render3d_st.grass_mesh2)
# the kind's row bands, inside the 40 m where the tiles grow to 16 m
let mid = Math.min(grass_kind(render3d_st).band_mid, 40.0)
let near = Math.min(grass_kind(render3d_st).band_near, mid)
grass_tiles(render3d_st, 4, 0.0, near, render3d_st.grass_mesh)
grass_tiles(render3d_st, 4, near, mid, render3d_st.grass_mesh3)
grass_tiles(render3d_st, 8, mid, 40.0, render3d_st.grass_mesh2)
grass_tiles(render3d_st, 16, 40.0, grass_reach(render3d_st), render3d_st.grass_mesh2)
}