Detail grass instancing, batching and improved detail falloff curve - #567
Detail grass instancing, batching and improved detail falloff curve#567samloeschen wants to merge 41 commits into
Conversation
36372f3 to
a05898d
Compare
…ets moved to VS as a result). compress instances to 64b, and sort by depth
a05898d to
2ba4173
Compare
|
Great optimizations. I ran this locally on my modified engine and it worked beautifully. Haven't noticed any visual artifacting or issues yet. |
|
looks dope, although I wonder if this can be implemented with MT branch as well, cuz it contains tons of rendering changes |
|
YES! finally biggest FPS killer gets some love |
|
MT version here, let me know if you want a separate PR: https://github.com/samloeschen/xray-monolith/tree/detail-grass-instancing-mt |
|
Yea please make separate PR for MT branch, would be easier for me this way
|
|
For merging into repo you need to do two things:
|
(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>
…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>
|
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: |
|
corrected shaders: I missed a typo Claude inserted when I was doing the dx10 stuff 🤦♂️ |

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 offr__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_restartsince 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.
