Skip to content

Overdrive cables: raised cross-section, shadows, and deferred lighting#5728

Open
Anarchid wants to merge 5 commits into
ZeroK-RTS:masterfrom
Anarchid:cable-tent-geometry
Open

Overdrive cables: raised cross-section, shadows, and deferred lighting#5728
Anarchid wants to merge 5 commits into
ZeroK-RTS:masterfrom
Anarchid:cable-tent-geometry

Conversation

@Anarchid
Copy link
Copy Markdown
Member

@Anarchid Anarchid commented May 26, 2026

What

Gives the overdrive grid cables a real presence in the 3D world, in three layered changes:

  1. Raised "tent" cross-section — the flat, ground-hugging ribbon becomes a raised tube.
  2. Shadow casting — the cables drop shadows in the engine's shadow-map pass.
  3. Deferred lighting — the cables participate in deferred lighting (projectile lights) and the outline pass, via the model gbuffer.

1. Tent cross-section

Why

Once the cables read as 3D tubes rather than painted stripes, the flat ribbon's lack of volume became obvious — especially where a cable crosses a cliff at an oblique angle. There the cross-sections only translated vertically (no rotation), so the cable looked like a "serpentine ladder" poking out of the terrain with no thickness.

How

  • emitMainRibbonemitTentHalf(side, …): emits one slope as a single triangle strip, ridge (cableUV.y = 0) → outer ground edge (cableUV.y = ±1).
  • The two slopes are dispatched to separate GS invocations (0 = left, 1 = right). This is what makes the full raised cross-section fit: each slope keeps its own GL_MAX_GEOMETRY_OUTPUT_COMPONENTS budget (~50 verts), whereas one combined sheet (~100 verts) would bust the 1024 min-spec ceiling.
  • The ridge is lifted along the local terrain normal, so on slopes/cliffs the tube banks into the surface (the terrain supplies a cross frame that stays ~perpendicular to the cable tangent) instead of displacing flat.
  • No fragment-shader change. cableUV.y = 0 was already lit as the cylinder top and ±1 as the sides, so the real raised geometry now agrees with the previously-faked cylinder normal instead of fighting a flat strip.
  • New constant TENT_HEIGHT_FACTOR controls ridge lift (0.0 reproduces the old flat ribbon).
  • Twigs moved to invocations 2–4 (capped at 3; invocation 1 was reassigned to the second slope).

2. Shadows (cast + receive)

Casting and receiving are unrelated mechanisms; both are wired up.

Casting re-draws the live cable VAO through a SHADOW_PASS shader variant from gadget:DrawShadowUnitsLua — the same callin cus_gl4 uses to shadow units, so cables drop into the same shadow map.

  • Identical tent geometry, but vertices go through the Recoil shadow-map transform (shadowView * world; xy += 0.5; shadowProj * — mirroring cus_gl4.vert.glsl; using the precombined shadowViewProj omits the +0.5 recenter and pushes geometry out of the shadow frustum).
  • The FS writes depth only and reuses the forward grow/wither discards, so a growing/withering cable casts a shadow trimmed to exactly its visible extent.
  • No information leak: own/spectator cables always cast; enemy cables cast only where they're in LOS (the same $info:los gate the visible cable uses); ghosts never cast.

Receiving samples the shadow map in the forward FS — the cable's sun term was a bare dot(cylNormal, sunDir) with an ambient floor and never tested the map, so it stayed at full sun under any occluder.

  • Uses the engine receive convention (shadowView * world; xy += 0.5; textureProj against a sampler2DShadow, as in map_lava.lua).
  • The coefficient darkens only the sun term — a shadowed cable falls to DIFFUSE_FLOOR (ambient), not black — and the specular. Bubbles are composited afterwards and stay emissive, matching the existing LOS-dim design (plasma reads as lights in the dark).
  • Gated on Spring.HaveShadows(): with shadows off, getShadowCoeff returns 1.0 without sampling and $shadow isn't bound.

3. Deferred lighting

Draws the live cable into the model gbuffer (cus_gl4's RENDERING_MODE==1 MRT) from a DEFERRED_PASS variant, run on gadget:DrawOpaqueUnitsLua's deferred invocation.

  • The FS writes the cable's cylinder normal + a capacity-tinted diffuse (plus depth, via the draw) into normtex/difftex instead of a lit colour. Deferred projectile lights then shade the cable's own surface rather than letting the terrain underneath bleed through it (which also explains a long-standing "terrain normals affect the cables" artefact — the light pass was reading the terrain gbuffer at the cable's pixels).
  • Bonus: the outline pass reads the same gbuffer, so it stops cutting through the cable geometry.
  • The forward DrawWorldPreUnit draw is unchanged — the visible cable, its lighting, and the animated bubble/pulse glow all stay forward-only, so the light pass can neither dim nor re-light the glow. The gbuffer is purely auxiliary geometry for screen-space effects, exactly as it is for units (which also draw both forward and deferred) — so there's no double-lighting.
  • The GS reuses the normal cameraViewProj path and skips the coverage-SSBO update in the deferred pass (the forward pass owns coverage; the deferred draw binds nothing at binding 6, and a second per-frame update could double-clear ghost bits). Ghosts and enemy-out-of-LOS cables are gated out of the gbuffer with the same rule the shadow pass uses.

Testing

/luarules reload, then:

  • Tent: inspect cables crossing terrain steps obliquely; set TENT_HEIGHT_FACTOR = 0.0 to A/B against the old flat ribbon.
  • Shadows: confirm cables cast into the shadow map and the shadow tracks growing/withering; scroll to check there's no shadow offset.
  • Deferred: pass a projectile light (Newton stream, firing unit, explosion) over a cable in shadow/at dusk — the light should rake the tube's near face with the rounded cylinder falloff, not a flat terrain-shaped wash.

Known follow-ups (deferred)

  • Left/right edge displacement doesn't yet respect the 2D downhill direction of the terrain (the B3 sign is chosen only to match perpAB).
  • Sheer cliffs are still under-segmented (boundaries are uniform in 2D t), so very vertical faces get few rings to drape over.
  • The bubble/pulse glow is not yet routed to the gbuffer emission target, so it doesn't feed bloom — a separate, optional nicety.

🤖 Generated with Claude Code

Split the flat ribbon into two single-sheet slopes (ridge at the
centerline, ground edges at cableUV.y=±1), one per GS invocation, so the
full raised cross-section fits: each slope keeps its own
GL_MAX_GEOMETRY_OUTPUT_COMPONENTS budget, whereas a single combined sheet
(~100 verts) would bust the 1024 min-spec ceiling. The ridge is lifted
along the local terrain normal so the tube banks into slopes/cliffs
instead of displacing as a flat "ladder" sticking out of the cliff face.

The fragment shader is unchanged: cableUV.y=0 was already lit as the
cylinder top and ±1 as the sides, so the real raised geometry now matches
the previously-faked normal instead of fighting a flat strip.

TENT_HEIGHT_FACTOR controls ridge lift (0.0 = old flat ribbon). Twigs move
to invocations 2-4 (capped at 3, one invocation reassigned to slope 2).

Known follow-up (deferred): left/right edge displacement does not yet
respect the 2D downhill direction of the terrain.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@Anarchid
Copy link
Copy Markdown
Member Author

image

A second shader program is compiled from the same VS/GS/FS with SHADOW_PASS
defined, and drawn from gadget:DrawShadowUnitsLua (the engine callin cus_gl4
uses to shadow units, broadcast to all gadgets).

- GS: reuses the tent geometry but transforms to the shadow map with Recoil's
  convention from cus_gl4.vert.glsl — shadowView, +0.5 XY recenter, then
  shadowProj, plus a small depth bias. (The precombined shadowViewProj omits
  the recenter and pushes the geometry out of the shadow frustum, which is why
  the first attempt cast offset/no shadows.) The coverage SSBO update is
  #ifndef'd out of the shadow pass.
- FS: keeps the grow/wither discards so a growing cable casts a matching
  shadow, then for own/spectator cables (gridData.w >= 1.0) writes depth only
  and returns before any lighting. Enemy live (0.0) and ghost (-1.0) cables
  don't cast, so the shadow pass can't leak un-scouted enemy grid.
- DrawShadowUnitsLua forces DepthTest/DepthMask on and draws the live cable
  VAO (ghosts, a separate VAO, are not drawn, so they don't cast).

Compile-failure-safe: if the shadow variant fails to build it is disabled and
the forward pass is unaffected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@Anarchid
Copy link
Copy Markdown
Member Author

Anarchid commented May 26, 2026

Added a second commit (99a0b5991) on top of the tent geometry: the cables now cast shadows, which does a lot to seat them in the world rather than reading as a flat overlay.

How it works:

  • Compiles a SHADOW_PASS variant of the same VS/GS/FS and draws it from gadget:DrawShadowUnitsLua (the same engine callin cus_gl4 uses to shadow units).
  • The GS reuses the tent geometry but transforms with Recoil's shadow-map convention from cus_gl4.vert.glslshadowView, +0.5 XY recenter, then shadowProj (the precombined shadowViewProj omits the recenter and casts offset/no shadow). Coverage SSBO bookkeeping is skipped in the shadow pass.
  • Compile-failure-safe and fully separable from the tent commit if you'd rather take just the geometry.

A deferred-lighting pass (projectile lights on the cables via the model gbuffer / DrawOpaqueUnitsLua) is the obvious next step but is not in this PR.

🤖 Generated with Claude Code

The first shadow pass only let own/spectator cables cast, to avoid leaking
un-scouted enemy grid. But enemy live cables are already rendered by the
forward pass whenever they are in LOS, so casting their shadow under the same
LOS test reveals nothing the player can't already see.

Shadow FS now gates by gridData.w: own/spectator (>= 1.0) always cast, enemy
live (0.0) casts only where $info:los >= ENEMY_LOS_CUT (mirroring the forward
visibility test), ghosts (< -0.5) never. DrawShadowUnitsLua binds $info:los so
the FS can sample it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@Anarchid
Copy link
Copy Markdown
Member Author

image

…fer.

Draw the live cable into the model gbuffer (cus_gl4's RENDERING_MODE==1 MRT)
from a DEFERRED_PASS shader variant, run on DrawOpaqueUnitsLua's deferred
invocation. The FS writes the cable's cylinder normal + a capacity-tinted
diffuse (plus depth, via the draw) into normtex/difftex instead of a lit
colour, so deferred projectile lights now shade the cable's own surface rather
than letting the terrain underneath bleed through it. As a side effect the
outline pass, which also reads the model gbuffer, stops cutting through the
cable geometry.

The forward DrawWorldPreUnit draw is unchanged: the visible cable, its
lighting, and the animated bubble/pulse glow all stay forward-only, so the
light pass can neither dim nor re-light the glow. The gbuffer is purely
auxiliary geometry for screen-space effects, exactly as it is for units (which
also draw both forward and deferred) -- no double-lighting.

The GS reuses the normal cameraViewProj path (no SHADOW_PASS) and skips the
coverage-SSBO update in the deferred pass: the forward pass owns coverage, the
deferred draw binds nothing at binding 6, and a second per-frame update could
double-clear ghost bits. Ghosts and enemy-out-of-LOS cables are gated out of
the gbuffer with the same rule the shadow pass uses, so the gbuffer never holds
a cable the forward pass wouldn't have drawn.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@Anarchid Anarchid changed the title Overdrive cables: raised "tent" cross-section Overdrive cables: raised cross-section, shadows, and deferred lighting May 26, 2026
@Anarchid
Copy link
Copy Markdown
Member Author

image image

Former: example of the outline shader cutting into the cable
Latter: example of how adding the deferred write solves the problem

The cables cast shadows and feed the deferred gbuffer, but their forward
fragment shader computed the sun term as a bare dot(cylNormal, sunDir) with an
ambient floor and never sampled the shadow map -- so a cable under a unit,
tree or cliff stayed at full sun. Casting and receiving are unrelated paths;
this adds the missing receive.

Sample the engine shadow map ($shadow) in the forward FS using the same
convention as map_lava.lua (shadowView * world; xy += 0.5; textureProj against
a sampler2DShadow). The coefficient darkens only the SUN term -- a shadowed
cable falls to DIFFUSE_FLOOR (ambient), not black -- and the specular. Bubbles
are composited afterwards and stay emissive, matching the existing LOS-dim
design (plasma reads as lights in the dark).

Gated on Spring.HaveShadows(): when shadows are off, shadowsEnabled is 0 and
getShadowCoeff returns 1.0 without sampling, so the FS never touches a
stale/absent map and $shadow isn't bound. The shadow/deferred shader variants
compile the same FS but return before the lighting section, so they're
unaffected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant