ajhahn.de
← Theria commits

Commit

Theria

feat: jungle short-grass ground in place of the flat plane

ajhahnde · Jun 2026 · 7de61100d25ed802f44e2fc04b2cb1a3eeed5e30 · parent: 99c91a5 · view on GitHub →

modified CHANGELOG.md
@@ -50,6 +50,11 @@ protocol version.
team colour is blended into the model's own albedo so blue and red read at a glance while each
model keeps its texture and species detail. Presentation only — the simulation and the netcode
protocol are unchanged.
- The ground is now a jungle short-grass surface in place of the flat dark plane: a procedural
shader breaks it into toon-quantised patches of two greens, cel-banded to match the units, so
the field reads as clumped grass under the models rather than a dead slab. The lane dirt paths,
the river, and the camp markers still lay over it. Presentation only — the simulation and the
netcode protocol are unchanged.
- The follow-camera now eases to the hero instead of locking to it 1:1, so a sharp turn or a
respawn glides the view rather than snapping it; and while the hero is gone (dead, or not yet
spawned) the camera rests where the hero last stood instead of jumping to the arena centre.
added src/client/ground.gdshader
@@ -0,0 +1,68 @@
shader_type spatial;
render_mode specular_disabled;
// The jungle short-grass ground. A flat plane on its own reads as a dead slab, so this
// breaks the surface into flat patches of two grass greens with a toon-quantised value
// noise — reading as clumped short grass from the close follow-camera — and bands the key
// light into the same flat tones the units wear (cel.gdshader), so a unit and the ground
// it stands on share one lighting treatment. World-space, so the pattern tiles evenly
// across the whole arena regardless of where the plane sits.
// The two grass tones the patches blend between — a deep shade green and a brighter one.
uniform vec3 grass_low : source_color = vec3(0.13, 0.30, 0.12);
uniform vec3 grass_high : source_color = vec3(0.22, 0.45, 0.18);
// How tight the grass clumps are: the broad-patch frequency and the finer speckle on top,
// per world unit, and how many flat steps the blend is quantised into for the toon look.
uniform float patch_scale = 0.004;
uniform float speckle_scale = 0.02;
uniform float patch_steps = 4.0;
// The two light levels the key light steps through — the same three-tone ramp the units
// wear, so the ground tone sits in the same family as the bodies on it.
const float MID_TONE = 0.5;
const float LOW_CUT = 0.25;
const float HIGH_CUT = 0.6;
varying vec3 world_pos;
float hash(vec2 p) {
p = fract(p * vec2(123.34, 456.21));
p += dot(p, p + 45.32);
return fract(p.x * p.y);
}
// Smoothed value noise in [0, 1] — bilinear blend of four lattice hashes, the building
// block the grass dapple is layered from.
float value_noise(vec2 p) {
vec2 i = floor(p);
vec2 f = fract(p);
float a = hash(i);
float b = hash(i + vec2(1.0, 0.0));
float c = hash(i + vec2(0.0, 1.0));
float d = hash(i + vec2(1.0, 1.0));
vec2 u = f * f * (3.0 - 2.0 * f);
return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);
}
void vertex() {
world_pos = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xyz;
}
void fragment() {
float broad = value_noise(world_pos.xz * patch_scale);
float fine = value_noise(world_pos.xz * speckle_scale);
float t = clamp(broad * 0.7 + fine * 0.3, 0.0, 1.0);
t = floor(t * patch_steps) / (patch_steps - 1.0);
ALBEDO = mix(grass_low, grass_high, t);
ROUGHNESS = 1.0;
METALLIC = 0.0;
}
// Bands the key light into three flat tones, the shadow side left to the ambient fill —
// the same step the units take, so the field lights as one surface.
void light() {
float ndl = max(dot(NORMAL, LIGHT), 0.0);
float tone = step(LOW_CUT, ndl) * MID_TONE + step(HIGH_CUT, ndl) * (1.0 - MID_TONE);
DIFFUSE_LIGHT += ALBEDO * LIGHT_COLOR * ATTENUATION * tone;
}
modified src/client/main.gd
@@ -67,8 +67,12 @@ const TOWER_SIZE := 110.0
const NEXUS_SIZE := 200.0
const STRUCTURE_HEIGHT := 220.0
## Ground plane + lighting, so the primitives read with depth instead of as flat dots.
const GROUND_COLOR := Color(0.114, 0.125, 0.145)
## Ground + lighting. The ground plane wears a jungle short-grass shader (GROUND_SHADER —
## toon-banded patches of two greens); behind it the sky is a dark jungle backdrop. The key
## light and ambient fill are tuned so the cel-banded units and ground read with depth
## rather than as flat dots.
const GROUND_SHADER: Shader = preload("res://src/client/ground.gdshader")
const BACKDROP_COLOR := Color(0.06, 0.12, 0.09)
const AMBIENT_COLOR := Color(0.52, 0.56, 0.64)
const AMBIENT_ENERGY := 0.5
const LIGHT_ENERGY := 1.1
@@ -609,7 +613,7 @@ func _world(p: Vector2) -> Vector3:
func _build_world() -> void:
var env := Environment.new()
env.background_mode = Environment.BG_COLOR
env.background_color = GROUND_COLOR.darkened(0.4)
env.background_color = BACKDROP_COLOR
env.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
env.ambient_light_color = AMBIENT_COLOR
env.ambient_light_energy = AMBIENT_ENERGY
@@ -625,7 +629,7 @@ func _build_world() -> void:
plane.size = MapData.BOUNDS.size
_ground.mesh = plane
_ground.position = _world(MapData.BOUNDS.get_center())
_ground.material_override = _flat_material(GROUND_COLOR)
_ground.material_override = _ground_material()
add_child(_ground)
MapView.build(self)
_camera = Camera3D.new()
@@ -898,6 +902,15 @@ func _flat_material(color: Color) -> StandardMaterial3D:
return mat
## The jungle short-grass material the ground plane wears: the shared grass shader, which
## breaks the plane into toon-quantised patches of two greens and cel-bands the light to
## match the units. A fresh instance so the one ground plane owns its own material.
func _ground_material() -> ShaderMaterial:
var mat := ShaderMaterial.new()
mat.shader = GROUND_SHADER
return mat
## An unshaded, always-camera-facing material with depth-test off, so a floating bar or
## label reads at full colour over the lit world and the foreground quad layers cleanly
## over its background by draw order rather than fighting it on depth.