Commit
Theria
feat: rim light and drop shadows on field units
modified src/client/cel.gdshader
@@ -20,6 +20,13 @@ uniform float use_vertex = 0.0;
uniform vec4 team_tint : source_color = vec4(1.0);
uniform float tint_strength : hint_range(0.0, 1.0) = 0.0;
// A hard-edged fresnel rim that lights the model's silhouette, so a unit pops off the
// jungle ground it stands on instead of blending into it. `rim_sharpness` is where the
// rim starts toward the edge (higher = thinner band); `rim_amount` how bright it burns.
uniform vec4 rim_color : source_color = vec4(1.0);
uniform float rim_sharpness : hint_range(0.0, 1.0) = 0.45;
uniform float rim_amount : hint_range(0.0, 2.0) = 0.8;
// The two light levels the matte (shadowed) tone steps up through, giving three flat
// bands in all. Eyeball-calibrated against the single key light and the ambient fill.
const float MID_TONE = 0.5;
@@ -38,6 +45,9 @@ void fragment() {
ALBEDO = mix(base, team_tint.rgb, tint_strength);
ROUGHNESS = 1.0;
METALLIC = 0.0;
float fresnel = 1.0 - clamp(dot(NORMAL, VIEW), 0.0, 1.0);
float rim = smoothstep(rim_sharpness, 1.0, fresnel);
EMISSION += rim_color.rgb * rim * rim_amount;
}
// Bands the key light into three flat tones with hard edges — the toon step. The
modified src/client/hero_model_library.gd
@@ -41,6 +41,16 @@ const HERO_MODEL_SIZE := 260.0
## material's albedo into it and folds the team colour in.
const CEL_SHADER: Shader = preload("res://src/client/cel.gdshader")
## The soft drop-shadow blob laid under every unit so it sits on the ground rather than
## floating: a flat quad washed by this radial-fade shader, sized to the unit's own footprint
## and scaled out a touch, set a hair above the ground decor (SHADOW_Y) so a unit's shadow
## reads over a lane or the river too. A blob, not a shadow-map cast — it grounds thin
## low-poly legs cleanly without a stretched, noisy silhouette.
const SHADOW_SHADER: Shader = preload("res://src/client/shadow.gdshader")
const SHADOW_COLOR := Color(0.0, 0.0, 0.0, 0.5)
const SHADOW_SCALE := 1.7
const SHADOW_Y := 3.0
## How far a hero's albedo is mixed toward its team colour (0 keeps the species colour, 1
## replaces it), strong enough to read blue or red at a glance while the species texture
## still shows through. Kept light so an already-dark mesh (the spider) is tinted, not
@@ -170,6 +180,44 @@ static func add_prop(parent: Node3D, prop: String, team_tint: Color) -> Node3D:
return model
## Lays a soft drop-shadow blob under a unit, parented to its view `root`: a flat quad sized
## to `body`'s measured footprint (scaled out by SHADOW_SCALE), washed by the radial-fade
## shadow shader, set at SHADOW_Y above the ground. Grounds every unit — hero, creep, or
## structure — against the grass. No-op for a footprint that measures to nothing. `body` must
## already sit under `root` in the tree for its footprint to resolve.
static func add_shadow(root: Node3D, body: Node3D) -> void:
var radius := footprint_radius(body)
if radius <= 0.0:
return
var blob := MeshInstance3D.new()
var quad := QuadMesh.new()
var diameter := radius * 2.0 * SHADOW_SCALE
quad.size = Vector2(diameter, diameter)
blob.mesh = quad
blob.rotation.x = -PI / 2.0 # lay the upright quad flat on the ground, facing up
blob.position = Vector3(0.0, SHADOW_Y, 0.0)
var mat := ShaderMaterial.new()
mat.shader = SHADOW_SHADER
mat.set_shader_parameter("shadow_color", SHADOW_COLOR)
blob.material_override = mat
root.add_child(blob)
## The horizontal half-extent of `body` about its parent origin — the furthest its merged
## mesh bounds reach on x or z, measured in the parent's space. Lets the presenter size a
## unit's drop-shadow blob to its actual footprint, so a wide tower and a slim chameleon each
## get a shadow that fits. `body` must be in the tree for its mesh transforms to resolve.
static func footprint_radius(body: Node3D) -> float:
var parent := body.get_parent() as Node3D
var inv := parent.global_transform.affine_inverse() if parent else Transform3D()
var radius := 0.0
for mi in _meshes(body):
var box: AABB = inv * (mi.global_transform * mi.get_aabb())
radius = maxf(radius, maxf(absf(box.position.x), absf(box.end.x)))
radius = maxf(radius, maxf(absf(box.position.z), absf(box.end.z)))
return radius
## The height of `body`'s top above its parent origin — the merged mesh bounds' max y,
## measured in the parent's space so a normalised model's own ground-standing offset is
## included. Lets the presenter float a hero's bars a fixed margin above whatever model
modified src/client/main.gd
@@ -736,10 +736,11 @@ func _make_view(entity: SimEntity) -> Dictionary:
view["body"] = _build_body(root, entity)
if entity.is_hero and HeroModelLibrary.has_model(entity.kit_id):
HeroModelLibrary.setup_facing(view, entity.kit_id, view["body"])
HeroModelLibrary.add_shadow(root, view["body"])
if entity.is_hero:
var ring := MeshInstance3D.new()
ring.mesh = _ring_mesh()
ring.position = Vector3(0.0, 2.0, 0.0)
ring.position = Vector3(0.0, HeroModelLibrary.SHADOW_Y + 1.0, 0.0) # over the shadow blob
ring.material_override = _flat_material(HUMAN_RING_COLOR)
root.add_child(ring)
view["ring"] = ring
added src/client/shadow.gdshader
@@ -0,0 +1,19 @@
shader_type spatial;
render_mode unshaded, cull_disabled, depth_draw_never;
// The soft drop shadow a unit casts on the ground — a flat quad laid under each unit, not
// a real shadow-map cast, so it grounds the unit cleanly without the stretched, noisy
// silhouettes a single low light throws across thin low-poly legs. A radial alpha falloff
// fades the dark disc out toward its rim so it reads as a soft blob rather than a coaster.
// The shadow's colour and peak opacity (at the centre), and how much of the radius is the
// soft fade-out edge (0 = a hard disc, 1 = fades the whole way from the centre).
uniform vec4 shadow_color : source_color = vec4(0.0, 0.0, 0.0, 0.32);
uniform float softness : hint_range(0.0, 1.0) = 0.55;
void fragment() {
float d = distance(UV, vec2(0.5)) * 2.0; // 0 at centre, 1 at the quad's inscribed edge
float fade = 1.0 - smoothstep(1.0 - softness, 1.0, d);
ALBEDO = shadow_color.rgb;
ALPHA = shadow_color.a * fade;
}