Skip to content

Detail grass instancing, batching and improved detail falloff curve - #567

Open
samloeschen wants to merge 41 commits into
themrdemonized:all-in-one-vs2022-wpofrom
samloeschen:detail-grass-instancing
Open

Detail grass instancing, batching and improved detail falloff curve#567
samloeschen wants to merge 41 commits into
themrdemonized:all-in-one-vs2022-wpofrom
samloeschen:detail-grass-instancing

Conversation

@samloeschen

@samloeschen samloeschen commented Jun 9, 2026

Copy link
Copy Markdown

Hi, I've implemented some improvements to grass instancing and specifically draw call batching that should provide some significant performance benefits.

in order of significance:

  • Grass draws are now batched, requiring no new pipeline state changes or buffer maps between grass draws. Even without instancing shaders, this should provide a significant CPU performance uplift and minor GPU performance uplift

  • Grass draw calls are now instanced. I noticed the other feature branches that have this on them, but implemented it separately so that it would work with the batching. Due to the batching, this is mostly a minor CPU performance uplift.

  • I've tried a custom falloff curve for grass which in my opinion looks a lot nicer than the default one. Grass is densely clustered around the camera, and falls off with a power curve after a certain distance. This is tunable with the parameters:

  • r__detail_density_knee: where along 0 -> r__detail_radius the grass begins to drop off

  • r__detail_density_curve: the shape of the power curve. >1 will have a gentle tail, <1 will have a sharp cliff ==1 will be linear.

currently, adjusting these parameters requires a vid_restart since it performs distribution at the spawn level, instead of the culling/submit level.

This is my first time modding xray itself so let me know if there's anything missing. I have found a mod-based workflow to work well for A/B testing, so I've included my modified exe + the necessary shaders to make it work as a zipped mod: Grass Instancing Fix.zip

quick example image from my testing. this is a 10-15 fps boost on my system (9950x3d, 3090) in this area. Less geometry is drawn overall but the visual quality is about the same if not better.
image

@samloeschen

Copy link
Copy Markdown
Author

alrighty, found a few more things to optimize. Big one is that grass instance lists are filled 3x per frame, but if we do fading in the vertex shader instead of on the cpu, we only need to fill once per frame. This also lets us further optimize the draw calls a bit. Two, is that the grass was previously unsorted. It's now sorted by depth (front to back) which really helps with preventing overdraw. Finally, I've compressed the grass instances into 64b, whereas they were 80b previously.

The same angle that was getting 65 FPS in my previous test now gets 82:

image

Here is a zip containing updated .exes and the grass shader mod you'll need to install through MO2. Be sure to just replace the exes in your bin folder, installing them as a mod doesn't seem to work.

grass optimizations.zip

@samloeschen
samloeschen force-pushed the detail-grass-instancing branch 2 times, most recently from 36372f3 to a05898d Compare June 11, 2026 03:46
…ets moved to VS as a result). compress instances to 64b, and sort by depth
@samloeschen
samloeschen force-pushed the detail-grass-instancing branch from a05898d to 2ba4173 Compare June 11, 2026 13:11
@TheLostInPlace

Copy link
Copy Markdown
Contributor

Great optimizations. I ran this locally on my modified engine and it worked beautifully. Haven't noticed any visual artifacting or issues yet.

@Priler

Priler commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

looks dope, although I wonder if this can be implemented with MT branch as well, cuz it contains tons of rendering changes

@erepb

erepb commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

YES! finally biggest FPS killer gets some love

@samloeschen

Copy link
Copy Markdown
Author

MT version here, let me know if you want a separate PR: https://github.com/samloeschen/xray-monolith/tree/detail-grass-instancing-mt

@themrdemonized

Copy link
Copy Markdown
Owner

Yea please make separate PR for MT branch, would be easier for me this way

MT version here, let me know if you want a separate PR: https://github.com/samloeschen/xray-monolith/tree/detail-grass-instancing-mt

@themrdemonized

Copy link
Copy Markdown
Owner

For merging into repo you need to do two things:

  1. Add changed vanilla shaders for grass instancing
  2. For shaders you need to make them compatible with DX10, since DX10 has no instancing. You can refer to grass-instancing-2 branch to see how it was done there

samloeschen and others added 15 commits June 18, 2026 20:41
(cherry picked from commit 4b9fe0938b316ccbdb65123cd3b13c1113bb5d88)
CSE_ALifeHumanStalker's constructor at xrServer_Objects_ALife_Monsters.cpp:2164
sets eTraderFlagInfiniteAmmo TRUE and the engine exposes no Lua surface to
clear that bit on the live game object. Adds int g_ai_unlimited_ammo (default
1) with free-function bindings get_ai_unlimited_ammo / set_ai_unlimited_ammo
registered at module scope in CAI_Stalker::script_register, and AND-s the
global onto the three live consumers: CAI_Stalker::unlimited_ammo (covers
every CWeapon::unlimited_ammo path via inventory_owner() dispatch),
object_actions.cpp:188 (planner try_advance_ammo box top-up), and
object_handler.cpp:115 (OnItemDrop ammo respawn).

Default 1 keeps the chain bit-identical to vanilla. The cvar is not in the
save format; mods reapply from actor_on_first_update each session.
Per review on PR 557. Registers g_ai_unlimited_ammo as CCC_Integer cvar
next to g_dispersion_base; drops the lua free functions and lua_help_ex
entries. Scripts set it via get_console():execute().
…c aim assist

Adds a new BOOL console variable (default TRUE, preserving existing behaviour)
that gates the zoomed ballistic arc aim assist in all CRocketLauncher-derived
weapons: WeaponMagazinedWGrenade (UBGL), WeaponRG6, and WeaponSSRS.

When TRUE the engine redirects the projectile to arc toward the crosshair
target via TransferenceAndThrowVelToThrowDir(), matching original behaviour.
When FALSE the projectile fires in the raw aim direction with no correction.

The option is exposed in the Modded Exes 3D Ballistics settings page with a
tooltip that clarifies scope (launcher weapons only; hand-thrown grenades and
RPG7 are unaffected).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Trim g_launcher_dynamic_range_zoom tooltip to a single readable line
- Add compressor/engine.log and compressor/mod/ to .gitignore
- Untrack compressor/engine.log (was committed previously)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Swap check for list_bool. The check widget is designed for CCC_Mask
bitmask variables and defaults to false, causing the toggle to break
on re-enable and the initial state to be out of sync with the actual
default of 1. list_bool correctly maps "1"/ON and "0"/OFF for plain
CCC_Integer variables.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The detail-object limit was 63/level, fixed by the 6-bit id fields in the
on-disk DetailSlot. Add a v4 slot (14-bit ids, 20 bytes, fields reordered to
pack into 3x u32, ID_Empty=0x3FFF) alongside the legacy v3 (6-bit, 16 bytes).

- DetailFormat.h: DetailSlot_v3 (legacy I/O), wide DetailSlot (working/v4),
  expand_v3/pack_v3, static_asserts on the 16/20-byte layout, r_id/w_id -> u16.
- DetailManager: read both v3 and v4 level.details; slots are copied into a
  heap-owned wide array (v3 expanded on load) instead of aliasing the VFS,
  freed in Unload. Object pool is now xr_vector; dm_max_objects = 16383 with a
  load-time guard.

Existing v3 levels load unchanged.
npc_on_enemy_selected
  CAI_Stalker__OnEnemySelected(npc, new_enemy)
  Fires in CEnemyManager::try_change_enemy() when selected() changes.
  new_enemy is nil on target loss. Replaces per-frame npc:best_enemy() polls.

npc_on_combat_action_changed
  CAI_Stalker__OnCombatActionChanged(npc, old_op, new_op)
  Fires in CStalkerCombatPlanner::update() on GOAP action transition.
  Operator integers match EWorldOperators: TakeCover=21, LookOut=22,
  HoldPosition=23, DetourEnemy=25, SearchEnemy=26.

npc_on_hit_reaction
  CAI_Stalker__OnHitReaction(npc, hit, bone_id, is_in_cover)
  Fires in CAI_Stalker::Hit() after inherited::Hit() applies damage.
  is_in_cover reflects brain().affect_cover() at time of hit.
  Mods can call npc:best_cover_invalidate() to force repositioning.

npc_on_take_cover_destination
  CAI_Stalker__OnTakeCoverDestination(npc, cover_pos, enemy)
  Fires in CStalkerActionTakeCover::execute() after setup_cover().
  Only fires when a real cover point is found, not on teammate-stacking
  or nearest-accessible fallbacks.

All hooks wired through SendScriptCallback in callbacks_gameobject.script.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
TheLostInPlace and others added 21 commits June 18, 2026 20:51
…entions

- Switch function declarations from keyword style to assignment style
- Trim verbose multi-line comments to single-line matching other NPC hooks
- No functional change

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…cally_wounded hook

Console variables (all read at action-execution time, no reload needed):
  ai_fire_make_sense_interval (ms, default 10000)
    How long after losing sight NPCs continue firing at last known position.
  ai_fire_precise_dist (m, default 2.5)
    Extra range past pick_distance() where fire_make_sense still returns true.
  ai_fire_floor_dist (m, default 2.0)
    Max vertical distance difference for fire_make_sense (multi-floor combat).
  ai_fire_near_dist (m, default 2.5)
    Minimum pick_distance() required for fire_make_sense.
  ai_cover_danger_radius / ai_cover_danger_ttl (3m / 120000ms)
    Danger zone when NPC is shot while in cover.
  ai_cover_detour_radius / ai_cover_detour_ttl (5m / 120000ms)
    Danger zone when NPC abandons cover to detour the enemy.
  ai_cover_unknown_radius / ai_cover_unknown_ttl (5m / 120000ms)
    Danger zone on unknown danger (sound, grenade, etc).

Hook: npc_on_critically_wounded
  CAI_Stalker__OnCriticallyWounded(npc, hit, bone_id)
  Fires exactly once per NPC on the frame they transition into critically
  wounded state. Never fires again for that NPC. Fires before BeforeHitCallback.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Console variable renames for consistency with ai_*_time convention:
  ai_cover_danger_ttl   -> ai_cover_danger_time
  ai_cover_detour_ttl   -> ai_cover_detour_time
  ai_cover_unknown_ttl  -> ai_cover_unknown_time

Console variable renames for clarity:
  ai_fire_precise_dist  -> ai_fire_range_extension (extra range past pick_distance)
  ai_fire_floor_dist    -> ai_fire_max_height_diff  (vertical gap check in fire_make_sense)
  ai_fire_near_dist     -> ai_fire_min_dist         (minimum engagement range)

Bug fixes:
  npc_on_combat_action_changed: guard against firing on first initialization
    (was_initialized check prevents passing u32(-1) as old_op to Lua)
  npc_on_hit_reaction: add g_Alive() guard so hook only fires on living NPCs

Lua: update npc_on_combat_action_changed comment to reference stalker_ids.action_*

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… danger scores

npc:best_cover_invalidate() Lua export
  Was missing entirely despite being the primary intended action for hit/danger
  reaction hooks. Scripts can now actually force NPCs to re-evaluate cover.

npc:rank_name() Lua export
  Returns the stable rank id string ("novice", "experienced", "veteran",
  "master", "expert") from CharacterInfo. More reliable than npc:rank() raw
  integer for rank-scaled behavior since mods change thresholds not names.

npc_on_danger_location_add hook
  CAI_Stalker__OnDangerLocationAdd(npc, pos, radius, in_open, cover_threatened)
  Fires in on_danger_location_add for every danger type: bullet ricochet,
  attack sound, entity attacked/death, grenade, enemy sound.
  in_open=true when NPC has no cover point (standing in the open).
  cover_threatened=true when danger overlaps the NPC's current cover.
  Previous early-return on !m_best_cover split into in_open flag so the
  hook fires for all NPCs regardless of cover state.
  Scripts can call npc:best_cover_invalidate() in response.

Danger score console variables (ai_danger_*_score)
  All hardcoded floats in CDangerManager::do_evaluate exposed as CCC_Float vars.
  ai_danger_attack_sound_score    (default 2500) - shooting heard
  ai_danger_entity_attacked_score (default 2000) - ally hit sound
  ai_danger_entity_death_score    (default 3000) - ally death sound
  ai_danger_corpse_score          (default 2250) - fresh corpse seen
  ai_danger_attacked_score        (default 2000) - attacked
  ai_danger_grenade_score         (default 1000) - grenade nearby
  ai_danger_enemy_sound_score     (default 1000) - enemy sound

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
npc_on_weapon_shot_start(npc, weapon)
  Fires in on_weapon_shot_start after effector is applied.
  Use to count continuous shots per NPC for rank-based burst control.

npc_on_weapon_shot_stop(npc)
  Fires in on_weapon_shot_stop when the NPC ceases firing.
  Pair with shot_start to measure burst length.

npc_on_should_throw(npc, flags) -> flags.ret_value
  Gate hook in CStalkerPropertyEvaluatorThrowGrenade, fires only when
  the throw trajectory is geometrically clear. Set flags.ret_value=false
  to cancel. Rank use: deny grenade throws for novices in complex terrain.

npc_on_missile_throw_force(npc, base_force, flags) -> flags.ret_value
  Return-value hook (_g lowercase, consistent with GetWeaponAccuracy) in
  missile_throw_force(). Override throw force by rank: rookies lob short,
  masters throw at full computed force.

npc_on_enemy_wounded_or_killed(npc, victim)
  Fires in on_enemy_wounded_or_killed after the kill-cry sound plays.
  npc = the attacker, victim = the target they wounded/killed.

npc_on_member_death_reaction(npc, member, is_alive)
  Fires in react_on_member_death after the tolls/wound sound plays.
  is_alive=true means the member was wounded (not killed).
  Use for rank-based morale: novices panic, veterans hold position.

Console variables:
  ai_close_move_distance      (default 1.5m)  - switch to in-place movement in TakeCover
  ai_crouch_look_out_delta    (default 5000ms) - crouch/stand toggle period in LookOut
  ai_wait_in_smart_cover_time (default 30000ms)- wait for enemy before leaving smart cover

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
_g.CAI_Stalker__GetMinCombatDist(npc, base_min) -> float
_g.CAI_Stalker__GetMaxCombatDist(npc, base_max) -> float
  Return-value hooks (Pattern A via B) in compute_enemy_distances().
  Override the min/max distance constraints used when selecting cover points.
  Defaults are computed from ef_weapon_type and the new console vars.
  Dispatched via npc_on_get_min_combat_dist / npc_on_get_max_combat_dist callbacks.
  Primary use: rank/weapon-aware cover selection (snipers seek distant cover,
  shotgunners seek close cover, independently of ef_weapon_type defaults).

npc_on_best_cover_changed(npc, cover_pos, is_smart_cover, smart_cover_name)
  CAI_Stalker__OnBestCoverChanged — fires in on_best_cover_changed() after
  internal delegates. cover_pos is nil when NPC loses its cover entirely.
  is_smart_cover=true when new cover is a smart cover position.
  smart_cover_name is the level object cName() of the smart cover.
  Primary use: squad coordination (detect when two NPCs select same position).

npc:get_current_smart_cover_name() -> string
  Returns cName() of the current smart cover object, empty string if none.

npc:get_current_loophole_id() -> string
  Returns id() of the current loophole within the smart cover, empty string if none.
  Both return "" safely on non-stalker objects (no engine error logged).

Console variables (all defaults match original hardcoded values):
  ai_cover_search_near_radius  (10m,  1–100)  first-pass cover search radius
  ai_cover_search_far_radius   (30m,  1–200)  second-pass cover search radius
  ai_cover_pistol_max_dist     (10m,  0–50)   max cover dist for pistol wielders
  ai_cover_shotgun_max_dist    (5m,   0–50)   max cover dist for shotgun wielders
  ai_cover_sniper_min_dist     (20m,  0–200)  min cover dist for sniper wielders
  ai_cover_default_max_dist    (20m,  0–100)  max cover dist for all other weapons

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
script_game_object3.cpp — new exports now log with object section name:
  rank_name(), best_cover_invalidate(), get_current_smart_cover_name(),
  get_current_loophole_id() all log "CGameObject: [section] called on
  non-stalker object" matching the established cNameSect_str() pattern.

ai_stalker_cover.cpp — compute_enemy_distances():
  - Remove goto in favour of if/else (cleaner control flow)
  - GetMinCombatDist / GetMaxCombatDist hook return values validated with
    _valid() and >= 0 check. Invalid returns log error with NPC cName()
    and the bad value, then fall back to engine-computed default.

ai_stalker_fire.cpp:
  - GetMissileThrowForce hook: return value validated with _valid() and
    > 0 check. Zero/negative/NaN returns log error with cName() and value,
    fall back to engine-computed force.
  - OnEnemyWoundedOrKilled: null check on wounded_or_killed before calling
    lua_game_object(). Null case logs error with attacker cName() rather
    than crashing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ipliers

Danger scores -> multipliers (ai_danger_*_mult):
  Renamed from _score to _mult, default 1.0 (= original engine behaviour).
  Formula: base_score * multiplier. Range 0.0-5.0.
  0.0 disables that danger type, 1.0 = vanilla, 2.0 = double urgency.
  Base scores hardcoded in do_evaluate() as before: 3000/2500/2000/3000/
  2250/2000/1000/1000. Parity with original engine confirmed at mult=1.0.

Tightened ranges on all other variables (engine min/max analysis):
  ai_fire_make_sense_interval  : 60000ms -> 20000ms (20s is already extreme)
  ai_fire_range_extension      : 50m     -> 10m
  ai_fire_max_height_diff      : 20m     -> 8m (2 floor max is realistic)
  ai_cover_danger/detour/unknown_radius: 50m -> 15m
  ai_cover_danger/detour/unknown_time  : 600000ms -> 300000ms (5min)
  ai_close_move_distance       : 20m     -> 6m
  ai_cover_search_near_radius  : 100m    -> 30m
  ai_cover_search_far_radius   : 200m    -> 80m
  ai_cover_shotgun_max_dist    : 50m     -> 20m
  ai_cover_sniper_min_dist     : 200m    -> 80m
  ai_cover_default_max_dist    : 100m    -> 50m
  ai_crouch_look_out_delta     : 30000ms -> 12000ms

Engine-aware minimums:
  ai_cover_pistol/shotgun/default_max_dist: min 3.0m = MIN_SUITABLE_ENEMY_DISTANCE.
    Below this the cover evaluator receives degenerate min==max inputs.
  ai_cover_sniper_min_dist: min 0.0 (0 = revert to engine default 3m minimum).
  All time vars: min 0 (valid and well-defined in all engine call sites).
  Cover search radii: min 1.0 (prevents zero-radius degenerate search).

All defaults unchanged — existing saves see zero behavior change.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Drop the post-damage hook in favour of exporting brain().affect_cover()
so callers can use the existing BeforeHitCallback pattern instead. Also
wire up the previously unregistered rank_name, get_current_smart_cover_name,
and get_current_loophole_id script bindings.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
These three bindings were already registered in script_game_object_script2.cpp
from an earlier commit. Only affect_cover belongs in script3.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- npc_on_take_cover_destination: gate hook behind pointer equality check
  so it fires only when the cover destination changes, not every AI tick
- ai_cover_sniper_min_dist: add > 0 guard so 0 means no-override rather
  than overriding minimum_enemy_distance with 0
- CStalkerActionTakeCover: add m_last_notified_cover member, reset in
  initialize()

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
25 new console variables (fire/suppression, cover danger zones, cover
selection distances, timing/movement, danger multipliers) and 3 missing
NPC method exports (rank_name, get_current_smart_cover_name,
get_current_loophole_id) alongside the previously added affect_cover
and best_cover_invalidate.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@samloeschen

samloeschen commented Jun 19, 2026

Copy link
Copy Markdown
Author

Alright, I used AI for this one (commit here) because I am unable to get the dx10 version of the exes to run in my environment. I had it follow the grass-instancing-2 branch you suggested. I've reviewed the code and it looks sane, but please smoke test.

updated shaders mod as well:
grass shaders.zip

@samloeschen

Copy link
Copy Markdown
Author

corrected shaders:
grass shaders.zip

I missed a typo Claude inserted when I was doing the dx10 stuff 🤦‍♂️

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.

8 participants