Skip to content

RTX rendering

Replay comparisons

Capture a tick list or inclusive range in one continuous playback per render mode:

uv run python tools/rtx/capture.py --game /path/to/game --replay /path/to/run.crd \
  --ticks 490,494,500 --modes classic,rtx --out /tmp/rtx-comparison

--ticks 490:500:2 captures every other tick through 500. --history 240 sets the initial rendered warmup; playback then continues through every requested tick without rewinding. Add --before-client /path/to/baseline/crimson to compare two builds. Each build/mode folder contains PNG frames, the original PPMs, a client log, and a manifest recording the replay hash, ticks, dimensions, and history window. Clients must support CRIMSON_CAPTURE_TICKS; missing frames fail the command.

The equivalent native environment is CRIMSON_CAPTURE=<existing directory>, CRIMSON_WATCH=<replay relative to game directory>, CRIMSON_CAPTURE_TICKS=490,494,500, and CRIMSON_WATCH_WINDOW=240. This is separate from CRIMSON_WATCH_MOMENTS, which retains independent seek/warmup windows for profiling isolated moments.

RTX is the Python port's alternative rendering mode. It combines analytic beam shaders, wet ground materials, and creature lighting with HDR world rendering and extended dynamic range (EDR) output on macOS and Wayland. This page covers those paths, their controls, and how they differ from classic rendering.

Background: how the original renders beams

In the native executable, beam-like projectile types are handled in projectile_render within the gameplay world render pass:

The important behavior for these weapons is:

  • beam body is built from repeated atlas samples along the shot vector
  • rendering uses additive blending for the streak/head look
  • beam head and special overlays (for example Fire Bullets glow and Ion chain behavior) are separate from the body strip stamping

That repeated-stamp body path is visually correct, but expensive in draw-call count on long/high-density scenes.

Rewrite classic path

Current classic implementation lives in src/crimson/render/world/projectiles.py:

  • _draw_streak(...), called from projectile_render's sprite pass, computes the streak's origin/head, segment range, and tint
  • the body stamps atlas sprites across the streak's last 256 units
  • head/overlay behavior remains on the classic path
  • Ion chain arcs and fade-stage core behavior stay in the shared beam draw path

This keeps parity-focused visuals as the baseline (classic mode).

RTX fast body/head path

The promoted RTX implementation lives in src/crimson/render/rtx/beam.py:

  • draw_beam_fast_stamped_body(...)
  • draw_beam_fast_stamped_head(...)
  • promoted from beam_debug shader_stamped_virtual under a production name
  • draws oriented quads and evaluates "virtual stamps" analytically in a fragment shader, instead of issuing repeated sprite draws

Integration behavior:

  • when render mode is rtx, beam body and head use the promoted shader paths
  • if shader compilation/load is unavailable, rendering fails fast (runtime error) instead of silently falling back to classic
  • Fire Bullets overlays and Ion chain behavior remain on the shared existing path

The analytic beam optimization covers streaks and heads while preserving shared overlay and chain behavior. The HDR pass below applies to the whole world.

RTX HDR world target and tonemapping

Implementation module: src/crimson/render/rtx/hdr.py.

When render mode is rtx, WorldRuntime.draw() wraps draw_world() in HdrRenderer.scope():

  • the world is drawn into a half-float (RGBA16F) framebuffer of linear light (see Linear light), so additive passes (beams, plasma globes, fire particles, detonations, muzzle flashes) add as light and accumulate past 1.0 without 8-bit per-channel clipping
  • pixels at or below the shoulder knee (0.7 linear, the original's 0.85 once encoded) pass through the tonemapper unchanged; the material pass below shades RTX terrain before this step
  • above the knee the peak channel compresses with a rational shoulder, whose slope eases from 1 at the knee, while the remaining channels scale proportionally to preserve hue: piled light keeps its gradation many times past white instead of flattening into it. Peaks past white drift smoothly toward an incandescent white core (0.6 of the way over 4.4 stops)
  • selecting RTX always requests EDR; macOS uses a verified floating-point OpenGL backbuffer and Cocoa's wantsExtendedDynamicRangeOpenGLSurface opt-in, following Apple's OpenGL EDR recipe
  • on Wayland, the window gets an RGBA16F EGL surface where the driver and compositor share a float format, else a 10-bit one, and is tagged through wp_color_management_v1 as gamma 2.2 sRGB whose signal 1.0 is the display peak and whose reference white is the compositor's; the canvas divides the frame by the shoulder's limit (headroom^(1/2.2)) so SDR white lands on the reference level. Headroom is the preferred description's target peak over its reference white, refreshed when the compositor reports a new preferred description (another output, HDR toggled). Compositors without parametric descriptions, set_luminances or gamma 2.2, and X11 sessions, stay SDR
  • the Python window runner reserves a deep surface for game windows so console/debug mode changes do not need to recreate the GL context; its temporary pixel-format hook (Cocoa) or EGL config filter (Wayland) is removed immediately after window creation
  • current display headroom is refreshed before each frame, including after display moves and fullscreen changes; potential headroom is not a rendering limit, and activation can ramp up from 1.0
  • the tonemapper rolls highlights off toward current headroom, linear as the world is, then encodes with gamma 2.2, the legacy OpenGL transfer; this is display-relative rendering, not a calibrated nit target
  • gamma and letterbox targets preserve extended values; HUD, aim indicators, perk prompts, and console overlays are drawn outside the world tonemapper and remain in the SDR range
  • returning to classic disables the Cocoa EDR opt-in or unsets the Wayland image description and composites through an 8-bit target, preserving classic's per-draw saturation even on the reserved deep window
  • Windows, unavailable deep surfaces, and displays with headroom 1.0 use SDR tonemapping; an SDR ancestor target also forces SDR output for capture/compositing

The native and browser clients run the same HDR world target and tonemap (see Native client and Browser client).

Linear light

Everything in the RTX world target is linear light: it blends and adds as light does, and the tonemap is the one place it is compressed and encoded for the display. One rule decides how a draw enters it (src/grim/linear_light.glsl): colors are the original's artwork or look-dev picks, gamma-2.2 encoded, and decode where they enter; amounts of light (an alpha, a glow's profile, an intensity) are already linear and stay as they are.

  • The world's sprites decode their color only while they draw into the world. The Python port's target stack marks the world target linear (texture_mode(..., linear=True)), and the alpha-test, beam and effect shaders read it; the clients mark draws that land in the HDR target. A bake inside the world, as the terrain's, takes artwork as drawn.
  • The fitted warm glows (effect_profiles.glsl) are premultiplied art: their hue decodes and their amount stays.
  • The lit passes (ground, creatures, players, bonuses, smoke, heat, contact glows) shade in linear light and write it as it is, with no limiter of their own: dense fights add up, and the tonemap alone compresses them.
  • Thin smoke veils dark ground more in linear light than it did blended in gamma, so the smoke is 0.4 times as thick per unit of density as before, and Monster Vision's heat trails take a stronger gain to stay readable added as light.

In-game PNG screenshots still use an 8-bit SDR readback, taken from the frame before Wayland's presentation scaling. Extended values clip to 1.0, so screenshots do not preserve the brightness or highlight detail visible on an EDR display.

Ground materials and wet blood

The Python RTX ground pass adapts the crimson-night-argon blood/material study, without its blackout night lighting, flashlight cones, smoke pass, or separate night mode. Unlit terrain retains approximately its classic brightness. Weapon lights add modest diffuse illumination and source-colored specular reflections. Classic continues to draw the original albedo target.

Implementation: src/crimson/render/rtx/materials.py, material_shaders.py, and material_lights.py. The material target follows the albedo target's allocated resolution and world coordinates, including texture scale and DPI:

  • RGBA32F: specularity, volume-weighted wetness, accumulated liquid volume, substrate height. Wetness uses a periodically rebased epoch to keep long-run calculations bounded.
  • RGBA8: ordered blood, corpse, brass, and ice coverage in RGBA. Separate RGB/alpha blend factors let ice accumulate coverage while every later material occludes it. Normal 0.35-alpha freeze shards receive an ice response without requiring repeated deposits. Ice occlusion is factored out when estimating the underlying film depth, preventing a shard from cutting a false liquid meniscus. Exposed ice retains the clean substrate/sprite color and its solid-surface reflection normal; blood deposited later reduces that ice coverage again. Later corpses cover old blood; later blood coats corpses again. Scorches remove gloss.
  • A separate clean substrate color target receives terrain, corpses, casings, ice, and scorches, but no blood RGB. Absorption therefore never re-tints the original blood-painted albedo. The latter stays intact for classic.
  • Blood volume accumulates with screen blending; absorption recovers optical depth from volume × coverage with -log(1 - amount) and an artistic scale. Wetness accumulates with the same blend weights as volume. Their filtered ratio keeps newly deposited thin spray fresh without letting it rejuvenate an entire old pool. Nonblood decals attenuate both channels equally, preserving the remaining blood's age. The clock follows presentation time, including slow motion, and pauses with the run.
  • Diffuse normals come only from pristine terrain/corpse/casing height. Blood changes neither this normal nor its diffuse relief. Separate liquid normals suppress fine dirt relief inside deep pools and add a filtered meniscus from volume × coverage, rather than tracing thin spray outlines. material_scope(debug=...) exposes the shader's substrate normal (1), liquid normal (2), freshness (3), blood amount (4), and linear dynamic specular contribution (5).
  • Fresh blood has a richer red body, including deep centers. The red body coefficient interpolates from 0.16 when fresh to 0.05 when aged; the deep-pool body weight similarly changes from 0.48 to 0.22. A broad depth transition replaces the bright shallow band, and the gentler liquid slope avoids an embossed pool outline. Wet soil darkening and absorption still distinguish depth from age. Freshness decays exponentially with a 45-second time constant: about 37% remains at 45 seconds, 14% at 90 seconds, and 2% at 180 seconds. Color and reflections fade gradually; blood coverage remains. These are artistic controls, not calibrated optical constants.
  • Independently bounded illumination and reflection sources feed the ground pass, ranked around the actual view center rather than player one (see weapon profiles below). A fixed orthographic virtual eye tilts backward by 30 degrees for specular only, placing reflections below their sources without moving the camera or dimming the arena. Moving players do not steer this viewpoint. The reflected eye ray intersects an emitter-height plane; distance to a finite tapered beam or point source determines the highlight. Roughness broadens and weakens its footprint instead of adding a broad second sheen lobe. Beam emitters sit at 40 world units; other sources retain their individual heights. Point-source blur spreads energy in two dimensions, degenerate segments fall back to points, and finite light support fades smoothly.
  • Substrate color inputs are decoded from approximate gamma 2.2; emitter colors are already linear radiance, so lifetime gain stays linear. Shading goes into the world's linear light as it is, unclamped until the HDR shoulder. No extra material tonemapper, exposure reduction, or full-screen darkness is applied.
  • Ambient blood absorption uses a stable incidence. Each dynamic emitter computes its own transmission and diffuse relief before contributing radiance, so a faint light cannot darken the ambient film or recolor another emitter's contribution.
  • Corpse/casing height is accumulated relief, H′ = H + (1 − H) × height × alpha, which raises the surface while retaining some terrain texture. It is not absolute replacement height; changing that appearance requires separate visual tuning.

Companions and shaders allocate only on the first RTX draw. Pending terrain bakes are encoded into a temporary spool that spills to disk beyond 1 MiB, allowing a late classic-to-RTX toggle without an unbounded Python object list. Applied batches are discarded once their state resides on the GPU. Stamping runs pass-major in bounded chunks, preserving each channel's decal order; cached uniforms and atlas-local UVs allow consecutive blood quads to batch. Replay seeks restore that history from the existing preparation log, discard stale companions, and rebuild lazily. New terrain and RenderResources.close() release the targets. Presentation metadata carries deposit time; gameplay state, RNG consumption, replay inputs, and native/browser rendering are unchanged.

Remaining tradeoffs

  • Ground direct light and reflections share each source's coarse visibility mask. Beams retain analytic directional penumbrae; reflections reuse the direct-light mask instead of tracing independent reflected rays. See Ground reflection occlusion.
  • Flame particles are grouped spatially, and a flame cell's light can still jump as the stream crosses cell boundaries.
  • Coverage, depth, and weighted wetness are per pixel, not a multilayer fluid simulation. Drying, roughness, meniscus width, and blood absorption still need broader art tuning.
  • The first RTX draw after a long classic session or a seek replays the material history synchronously and can hitch. Pending spool disk usage still grows with deposits until RTX consumes it, terrain resets, or the run closes. GPU companions and clean substrate cost 32 bytes per terrain pixel (32 MiB at 1024², 128 MiB at 2048²), including both RGBA8 targets' depth attachments. Packing channels, removing the unused depth attachment, and incremental reconstruction are worthwhile follow-ups.

A synchronized OpenGL comparison at 031f97307 on an Apple M1 Pro at 960×540, using the Survival fixture at ticks 4395/5115/6255 (30 warmed draws each), measured median whole-world RTX draw times of 3.95/3.59/3.53 ms before materials and 4.24/3.79/3.82 ms with them. These small samples include CPU submission and GPU completion, exclude simulation and new decal bakes, and are not a general performance guarantee.

A separate queued-bake benchmark used a 1024² terrain target inside a 960×540 target, 400 batches containing 4,934 mixed blood/casing/scorch/corpse stamps, and six synchronized runs (first discarded). Pass-major stamping reduced the warm median from 1,568 ms at 502e0994d to 571 ms (2.7× faster). Initialization and queue encoding were outside the timed region. Continuous play normally bakes only new deposits; first-activation latency still depends on the accumulated backlog.

GPU tests cover ordered composition and equivalence between incremental and pass-major bakes, actual shader normal outputs, thin/deep meniscus behavior, volume-weighted freshness at ten hours, linear HDR emitter response, continuity at a fading light's boundary, independent light contributions, backward reflection displacement, point/beam shape, finite degenerate emitters, fresh/deep color and reflection aging, actual expired-shard ice response, ice-over-blood color and depth, and blood-over-ice ordering, clean substrate color, sampler binding, regeneration, and teardown. CPU tests cover spool rollover/cleanup and view-centered co-op light selection. Existing seek/reset lifecycle checks remain; RTX replay optimization is deferred.

Render mode controls

Render mode enum and helpers: src/crimson/render/rtx/mode.py (classic|rtx).

Mode changes are accepted only at launch or from the main menu and its Options panel. Runs, paused games, replays and in-game Options keep their renderer for the entire run.

Runtime/launch controls:

  • Options: the RTX lighting box, below the sliders, in place of the original's Graphics detail slider
  • launch flag: --rtx (game entrypoint or crimson replay play <replay.crd> --rtx)
  • console commands: rendermode <classic|rtx>, togglertx

The box and the console commands are the player's choice, which render_mode.txt in the runtime folder keeps for the next launch; the native client shares the file. --rtx starts that one launch in RTX. - debug hotkey: F4 toggles mode (debug builds)

Benchmark controls:

  • render benchmarks can select mode via:
  • uv run crimson replay benchmark <replay.crd> --mode render
  • uv run crimson replay benchmark <replay.crd> --mode render --rtx
  • replay videos render RTX too, tonemapped for SDR: uv run crimson replay render <replay.crd> --rtx (--no-rtx-smoke without the fluid smoke)

Golden scenes

tools/rtx/scenes.toml names replay moments that exercise each RTX path: - aged blood with almost no light - frozen creatures, and fire across ice - rocket smoke - the nuke - ion beams through smoke - dense beam lights - a Shock Chain crowd

uv run python tools/rtx/golden.py capture --game <game folder> --out <dir> captures each one, classic and RTX, with the native client. The client seeks a little before the tick, pauses on it (CRIMSON_CAPTURE_TICK) and records the median GPU time per pass over the frames leading to it from a profile (CRIMSON_PROFILE, tools/profile.py). golden.py compare <before> <after> puts two captures side by side with a difference map, the mean change and the GPU time per scene. Captures tonemap for SDR, so they read the same on any display.

A profile's passes are the marks the frame passes: frame from the last swap to the first mark, holding the frame's simulation ticks, the world's passes, hud, present and swap. Drawing off the back buffer in between has passes of its own (bake for terrain decal bakes, heat-stamp for Monster Vision's trail stamps, hold for the replay viewer's kept frame), so frame holds no drawing. Classic marks the world's bounds too (world, then hud), with nothing drawn differently. A pass's GPU time also counts the time the GPU waits for the CPU, so frame's is idle time and profile.py and golden.py leave it out of the GPU's sum. A pass's CPU time runs from its mark to the next, so it also holds the time the driver blocks the CPU on the GPU. On macOS the timer queries themselves flush the driver's command buffer at every mark, so a frame timed this way drains the GPU pass by pass and waits in the pass after a costly one: with every frame timed, late in a run the players pass showed the ground's GPU time as its own CPU time, and frames ran about 4 ms slower. The client therefore times every 8th frame by default, and profile.py reads wall and CPU times as means over runs of 8 frames, where an untimed frame waits at the swap. Each frame's draws are counted per pass, texture (or beam / effect<kind>) and target as the quads drawn and the target pixels they cover.

The two RTX renderers share their GLSL, but light collection and selection, volley merging, beams and smoke are ported twice (render/rtx/*.py, crimson-core/game/rtx_*.cpp, crimson-core/client/renderer*.cpp). golden.py parity --game <game folder> --out <dir> captures the scenes with the client (<dir>/client), draws them with the Python port (<dir>/python, also golden.py python) and compares the two: a sheet per scene and mode (client, Python, difference), the full-size difference map and summary.json. The Python side plays undrawn to the client's seek, then draws every tick up to the scene's, as the client does, in the client's 1024x768 window at the display's density, tonemapped for SDR. Each frame's HUD and aim overlays (where the Python frame differs from its world alone) and the client's replay bar are reported apart from the world. The client frame is aligned to the Python one to the pixel first (the shift is reported, with the unaligned difference): a renderer placing everything a pixel off differs at every edge. Classic shows how far the two draw apart outside RTX (filtering, terrain decals), so the RTX difference beyond the classic one is what to look into. --window n lets the Python frame up to n ticks either side stand in, and --views saves rtxdebug views of the Python frame to narrow a difference down to a pass.

Analytic glow effects

render/rtx/effects.py and the client's host_effect draw the same effect.frag:

  • Plasma's uniform tail stamps become a finite Gaussian line integral, with its head and optional aura in the same quad.
  • The warm glow (particles effect 13) fits premultiplied RGB with three Gaussian lobes; the white aura (effect 16) fits alpha.
  • Ion impact arcs combine two constant-width soft capsules and the target glow, retaining the original-bug setting's cut ends. Each corner carries the arc's length, so consecutive arcs batch like the radial sprites.
  • The expanding ring (effect 1) uses a broad inward radial polynomial and a compact outer rim, including elliptical and rotated draws.
  • The Pulse Gun's shot (projs cell 0 of 2x2) is a dome: a circular front edge and a body fading behind it, thinning toward its straight sides and cut square at the back. It draws at the sprite's size, tint and white impact, a little brighter, with an HDR glow along the middle of its front edge that dims instead of swelling at sub-pixel sizes.

The profiles are fitted to the original atlas cells in effect_profiles.glsl. Their byte-alpha cutoff keeps faint layers from reappearing during fades. Fireball, smoke, blood, shard and casing artwork retains its existing drawing, as does Classic mode. Warm glows under normal alpha blending retain the original texture coverage. Consecutive radial sprites batch by shape with per-vertex tints, ending at texture, blend, target or parameterized-effect changes; natively they submit wherever Grim flushes or ends its own batch, so they keep their place and blend state. Native hooks retain Grim's computed corners and transformed vertices, including its rotated non-square quads. Suppressed rendering discards pending analytic batches. These are appearance approximations; fewer submitted stamps alone do not establish a performance improvement.

Native client

The native client (crimson-core/client) shows RTX through the same GLSL as the Python port: src/crimson/render/rtx/shaders/*.frag are loaded by Python and built into the client's rtx_shaders.h (crimson-core/client/shaders.py). The client renders the original game, so it cannot branch inside a Python draw path; instead the game module marks where the world starts and ends in a frame (crimson-core/game/rtx/rtx.patch, game/rtx.cpp), and the host (client/renderer.cpp) draws the world between those marks into an RGBA16F target, tonemaps it onto a half-float back buffer, and draws the HUD over it. Classic keeps its 8-bit back buffer, so each classic draw still saturates at white. Creature bodies and players are lit as in the Python port, with the same creature.frag and player.frag (moved into shaders/ with the shared source_geometry.glsl and light_visibility.glsl). The game module collects the frame's lights at the world's start (crimson-core/game/rtx_lights.cpp, a port of material_lights.py): projectiles and their ion arcs, muzzle flashes, the Sharpshooter sight, the Radioactive aura, rocket exhaust, detonations and grouped flame particles, and explosion fireballs and shells, with the same idents, volley merging and per-response selection and fades (on run_elapsed_ms, reset with the run), and draws the ion arcs' contact glows in their own pass (HOST_PASS_GLOW) as the creatures begin. Like ExplosionVisual, the module records each explosion burst as it spawns: its fireball sprites as they start out, and whether its ring spawned. Rocket hits stay dark, their detonation lighting them instead. The records age with the effects' update and are kept apart from the run. A freed slot leaves its record (effect_free), so a reused slot is never mistaken for one. The records also draw the explosion as draw_effect_pool does in RTX: - the explosion's own ring becomes the warm shell (the RING cell tinted (190, 163, 122) at the shell's radius and alpha); a rocket's ring isn't drawn - a lit explosion's fireballs are drawn from how they spawned, at half speed (1.4 s), in place of their native slots

Each frame the module sends the shells as pressure fronts (host_pressure, at most 16). Before the tonemap, the host displaces the world through pressure.frag into a second HDR target. The client's targets run v down, so client/shaders/pressure_lit.frag defines TARGET_V_DOWN. The module marks the creature body and player batches (HOST_PASS_CREATURES, HOST_PASS_PLAYERS), and the host draws them with the lit programs, the camera offset giving each fragment its world position. Creature bodies keep the 0.60 ambient fill even without lights; players keep the device's shading until lit. Thin-part transmission uses the same part-size map (crimson-core/client/thickness.cpp, a port of creature_translucency.py), made from the atlas texels the module keeps on a creature atlas's first lit draw. Creature shadows use the same body grid (rtx_build_occlusion, a port of reflection_occlusion.py): with shadows enabled, the module builds it from the creature pool against the selected lights and sends it each frame (host_occlusion, crowd cells included), and the hosts upload it as the RGBA32F texture the shader walks (unfilterable in WebGPU, which reads it with textureLoad alone). The shadows setting turns direct-light visibility off as in the Python port. Bonuses take the same bonus.frag: the module marks each shell, icon and weapon icon quad (host_bonus, with its atlas grid and the bonus's position as the shell center), and the host draws it with the bonus program whether or not lights reach it, as draw_bonus_pickups does.

The ground takes the same ground.frag and ground_stamp.frag (the client's textures run v down the world, so it defines GROUND_V_DOWN). The module marks the terrain's decals in fx_queue_render by the same materials as MaterialGroundRenderer._commands (host_ground): blood, corpse bodies, casings, black scorches and freeze shards, or no material. Each mark carries the run's clock, which ages wet blood. While RTX is on, the host keeps the same three companions of the terrain target and draws each marked decal into them: the substrate (everything but blood), the ordered coverage, and the float surface's three stamp passes. The native client queues a bake's marked decals and stamps them pass-major as the Python port does, each companion bound once and its decals in the bake's order (the WebGPU client still stamps each decal as it lands). It draws the terrain into the world with the ground program (HOST_PASS_GROUND). The lights carry what the ground adds: the reflection tail, broad illumination, and a beam's energy shape, computed by the module as continuous_beam.py does (its support also sizes the body grid). The companions start from the terrain as it stands the first time RTX needs them. A clear or leaving RTX drops them. Replay copies preserve the substrate, coverage, float surface, and wetness epoch; restoring the clean initial copy removes any later materials. Native prepared caches store the same planes in CWC2 files. A seek bakes the logged decals after that copy at their logged clock. Native reconstruction batches up to 256 material draws across bake boundaries, preserving order within each plane and flushing before wetness rebases or reads. Render mode changes are allowed at launch and from the main menu (including its Options panel), never during a run, replay, preparation, or in-game Options. WebGPU keeps the surface in 32-bit floats where the device filters and blends them, otherwise in half floats. When a run returns to a menu, the retained terrain still uses its material shader inside the HDR composition pass. Its blood film, substrate and wetness epoch remain at the run's last gameplay presentation time, before quest results reuse the clock for their bonus-adjusted score. Only the terrain-only menu pass suppresses dynamic lights, heat footprints and pressure fronts: the gameplay world's death transition and end screens retain their effects. Fresh RTX menus use the same material path in all clients. Python transfers the ground before closing the run and uses a separate, lazily allocated menu HDR target. Monster Vision is heat, as in the Python port (see Perk illumination; crimson-core/game/rtx_heat.cpp). While RTX draws, creature_render_all skips the native aura quad and marks the trails instead (host_heat_trails, drawn with heat_trail.frag under GROUND_V_DOWN); after each species' bodies, rtx_heat draws the same frames again as HOST_PASS_HEAT, red carrying warmth and alpha the overlay's opacity, which the host shades with heat_body.frag over the atlas's part sizes blurred within each cell. Before the world begins, the module sends every warm monster's footprint (host_heat); the host fades its world-space trail buffer by the run's clock and stamps them (heat_stamp.frag). The golden scene hidden-evil (tools/rtx/scenes.toml) is a real Hidden Evil run.

Fluid smoke takes the same solver and display shaders (smoke_solver.frag, smoke_splat.frag, smoke_impulse.frag, smoke.frag). The module captures what sim/smoke_events.py captures as each tick runs (crimson-core/game/rtx_smoke.cpp, hooks in game/rtx/rtx.patch): - sprite smoke spawns, read back after their caller colors them - explosion puffs, with their expansion, as the fireball starts - muzzle puffs, from guns, Fire Cough and Typ-o-Shooter's shotgun, which follow the firing direction, each with a jet of air - rocket exhaust, which moves back along the flight, and detonation sprites - the nuke's and Final Revenge's 15-second billows - the smoke of creatures burning in a flame, as flame soot - projectile and rocket wakes, and blast fronts from explosion bursts and rocket detonations - the drag of moving creatures, their sliding corpses, and players

These hooks only read the run: no RNG, no state, and the verifier builds without them. The module also runs GpuSmokeSimulation's CPU side: retention and fade, impulse budgets and fixed 60 Hz steps. It hands the host the solver passes as a command stream (host_smoke). The host runs them on its five RGBA16F field targets. After the world's sprites (bonus_render, which ends with the effects), the host integrates the display under the smoke's 16 lights and the Sharpshooter sights (host_smoke_draw) into a composite at most 640 pixels across, and puts it over the world premultiplied. While fluid smoke runs, native smoke sprites and explosion puffs are not drawn. The console's rtxsmoke on|off switches it, on by default. The Smoke display setting gates it too. A mode change, a new run, or a restored run (a replay's seek, a co-op rollback) clears the field.

Ion and Fire Bullets streaks draw as the same analytic beams as the Python port's (shaders/beam.frag): the module computes each body and head quad as render/rtx/beam.py does and hands it to the host (host_beam) in place of the original's stamps, in the head colors the classic path picks (original bug 41 included). On macOS the window is half-float from the start and opts into EDR while RTX is on, following the headroom SDL reports each frame. Other platforms tonemap to SDR. The mode never reaches a run's state, and the verifier (core.wasm) builds without it.

Controls (mode changes only at launch or from the main menu and its Options):

  • Options: the RTX lighting box (crimson-core/game/rtx/options.patch) replaces Graphics detail: Mouse sensitivity moves up into its row and the box goes below the sliders, beside UI Info texts; crimson.cfg keeps the detail preset, the most detail by default
  • console commands, as in the Python port: rendermode <classic|rtx>, togglertx, rtxsmoke <on|off>
  • launch flag: crimson --rtx, or CRIMSON_RTX=1 or 0 (unattended captures), for that launch only

The box, rendermode and togglertx are the player's choice: the game module (game/rtx.cpp) writes the mode drawn to render_mode.txt in the game folder, which the Python port shares, and a launch without a flag starts in it. It lives outside crimson.cfg, which co-op hashes and live ticks swap with the run's copy. - rendersize <multiple|native> caps the world's pixels at a multiple of the game's resolution, at least 1 (2 by default, the terrain's own density), or lifts the cap. On a larger display, such as a 5K screen in full screen, the world draws at the cap and the tonemap scales it up, while the HUD and its text keep the display's pixels; past twice the game's resolution a display gains little sharpness for a GPU cost that grows with every pixel. Classic always draws at the display's resolution. The browser's WebGPU renderer takes the same cap

Browser client

In the browser, RTX needs the WebGPU renderer (crimson-core/client/renderer_webgpu.cpp): WebGL2 has no extended-range output, so the WebGL2 renderer stays classic. The WebGPU renderer runs the same world target, tonemap and beams through the same GLSL, translated to WGSL (crimson-core/client/shaders.py). On an HDR display (dynamic-range: high) the canvas turns rgba16float with extended tone mapping while RTX is on. Browsers report no headroom, so the tonemap rolls highlights off toward twice SDR white, which most HDR displays show without clipping. Elsewhere it tonemaps to SDR.

The page's RTX switch sits beside fullscreen and shows only with WebGPU. It makes the same choice as the Options box, kept in the game folder in the browser's storage, and follows the mode whatever changed it. ?rtx=1 or ?rtx=0 picks the mode for a link without changing the choice. A choice the switch saved in localStorage before the game kept it moves into the game folder once.

Future direction

crimson.render.rtx is intended as the home for non-classic render enhancements (beam acceleration, HDR additive tonemapping, and EDR output now, advanced lighting next), while keeping classic as the parity reference path.

Player lighting

Python RTX players receive the shared bounded weapon emitters through a separate opaque cloth shader. The treatment combines stronger diffuse dome lighting, painted texture relief, and a directional inner rim. It preserves the original single-player and co-op tints and keeps a full ambient floor. There is no player specular, transmission or material mask; a player casts a body shadow (see Ground reflection occlusion) but receives none. The dome coefficient is 2.2, rim coefficient 0.8 and relief strength 0.65; the added light is not limited, as the tonemap compresses it with the rest of the world. A small albedo lift and broad facing floor make the response readable on the already-painted atlas. These are art-directed sprite normals, not 3D anatomy.

player_lighting.py owns a lazy shader and player_shaders.py defines its response. The world pass selects the light list once for player and creature lighting, then uploads it before drawing players. Living legs and torso share one shader scope; the visible death sprite uses the same treatment. Black sprite shadows, radioactive auras, shields, and muzzle flashes retain their original draw paths. With no lights, the player uses the original shader without allocating the lighting shader. Classic mode always uses the original path, including after switching back from RTX.

Per-layer orientation comes from UV/world derivatives evaluated before alpha discard, with an identity basis for singular UV mappings. Relief and rim taps clamp to the current animation cell. The base sprite keeps the original alpha-aware filtering and 4/255 cutoff. Point, finite beam, and annular illumination reuse the shared source geometry and illumination gain; reflection-only tracers do not light player cloth. Beam illumination comes from the shared beam profile's energy centroid (see weapon illumination below), with squared radial attenuation.

Creature lighting

Python RTX creature bodies receive the same bounded weapon emitters as the ground pass. A rounded body normal and weak texture relief provide broad colored lighting and small highlights; three alpha taps toward each light produce an inner silhouette rim. The approved blend uses 65% of the dome study and 65% of the rim study; their combined light, thin-part transmission included, goes to the tonemap unlimited. These are artistic shape estimates, not authored normal maps or physically calibrated skin/chitin models.

Implementation: src/crimson/render/rtx/creature_lighting.py and creature_shaders.py. Point emitters and finite beam segments both work in world coordinates. The sprite basis is recovered from interpolated coordinates, so rotation and size do not require per-creature uniform uploads or batch flushes. Basis derivatives are evaluated before alpha discard, with an identity fallback for singular UV mappings. Texture relief uses central differences at offsets of one atlas texel, rotated into world space, so its strength does not depend on framebuffer resolution. Both relief and alpha taps stay inside the current animation cell. Lights are uploaded once before the species passes. Specular beam intensity follows the shared beam profile along the segment, and illumination comes from that profile's centroid, independently of squared radial attenuation. Point lights and the native alpha cutoff remain intact.

Thin parts also scatter lateral light, tinted by their pigment. src/crimson/render/rtx/creature_translucency.py estimates part size from each animation cell's alpha silhouette with the same alpha cutoff of 4/255. It finds the largest fully contained odd square covering each pixel, capped at 31 texels. This is equivalent to accumulating nested square morphological openings: erode, then dilate each surviving region back to its extent. Unlike boundary distance, it assigns thick support to a broad body's edges as well as its center. Descending max-dilation reconstructs all sixteen levels in fifteen steps; cells remain isolated throughout. Transmission falls as exp(-0.48 * size), where size is the square's radius plus one, with an artistic strength of 1.4. Its pigment multiplier is mix(vec3(0.15), baseLin, 0.85); even black parts retain a small 0.0225 floor. The lateral response uses the light's elevation, independent of the cell-center dome normal, so a narrow limb can scatter on either side of the sprite. The shader samples this map once per body pixel and adds its directional response inside the existing light loop, sharing each emitter's finite source, radial attenuation and visibility. It preserves sprite alpha. This is a silhouette part-size appearance model, not anatomical thickness or traced subsurface scattering. Square openings are orientation-dependent and can still classify curved tips and corners as thin. The model does not distinguish skin from claws/armor. Rounded body visibility now accounts for intervening creatures, but the transmission calculation itself remains an artistic approximation.

Thickness textures are generated from the loaded source atlas on its first lit RTX body pass, cached by that atlas's texture ID, and released when the world's lighting resources close. Empty species and unlit/classic draws do not generate them. Each 512×512 atlas adds a 256 KiB single-channel texture without mipmaps; generation has a one-time readback and CPU preprocessing cost. Atlas binding happens once per species body batch, with no per-creature texture uploads or additional render pass.

Creature body passes and the freeze's ice (below) use this shader. Contact shadows, hit flashes, poison overlays, players, and baked corpses keep their existing paths. RTX creature bodies retain a 0.60 linear ambient fill, with dynamic visibility applied per emitter. Classic bypasses lighting entirely. GPU resources are lazy and released with the world's render resources. The existing HDR world target and display shoulder handle the added highlights.

Freeze ice

The freeze's ice cubes, over frozen creatures and in the shatter, and the shatter's shards are lit as ice by the creature shader (u_ice, the grid of their particles cells). The original cube is a render of a rounded, lumpy cube, and the shards are chunks of the same ice. tools/rtx/ice_surface.py recovers a surface for each from the original art: for the cube, a rounded box bent by smooth terms, traced through the render's camera (both fitted to the sprite's corners, outline and shading); for a shard, a dome grown from its outline. A height field on top makes each body's relative shading match the sprite's under one light, and what that shading leaves of the sprite is the ice's own mottling. The result, src/crimson/render/rtx/ice_surface.tga, keeps the atlas's layout and takes the thickness map's place for ice draws.

Unlit, the ice keeps the original's look, a little colder, plus a cold sky reflected in the faces that turn toward it. The lights shade an ice colour with the mottling but not the sprite's baked light, so a fire on the dark side lights that side. Ice scatters little and keeps its blue under fire: lights mostly glint off it (five times the body's catchlight, three times as sharp), its scattered light keeps 55% of its colour, the silhouette rim keeps a quarter of its strength so overlapping pieces don't outline each other, and nothing transmits. The ice is a little more solid than the original's overlay (alpha x1.15, at most 0.95). Over a frozen body, the cube's darkest faces lose a third of that (to about 0.55), so the body shows through while its bright frost and glancing edges stay solid; the shatter keeps the full opacity.

The module marks the ice's batches: the freeze overlay's batch in creature_render_all (HOST_PASS_ICE_FROZEN), and among the effects (HOST_PASS_ICE_CUBES, HOST_PASS_ICE_SHARDS) a mark only where an effect's material changes, so the shatter keeps its place in the effects' order.

A dense Freeze burst leaves a thousand or more loose cubes and shards, each lit in full, so RTX draws a bounded share of them (crimson-core/game/include/rtx_debris.h, and render/rtx/debris.py for the Python port, which a test holds to the same selection frame by frame): at most 256, taken in turn from the screen's 8x6 tiles in a stable order by pool slot, within half the screen's area. A piece leaving the selection fades out over 80 ms of the run's clock as its replacement fades in, and at most 320 draw at once. A respawned slot, a rewind and a gap of more than a quarter second start over. Only which pieces draw, and how opaque, changes: the effect pool, its order and the decals it bakes are the original's, and frozen creatures' ice is never budgeted.

Bonus materials

Python RTX replaces the pickup bubble bitmap with an analytical dark sphere and lights the original bonus and weapon icon artwork as a shallow foil/enamel emblem. The Argon bonus study supplies the visual baseline: a black core, the original top-right studio highlight, directional edge crescents, and curved reflections of finite weapon beams. The opposite-wall glow suggests internal refraction; these are artistic material approximations, not a calibrated glass simulation.

Implementation: src/crimson/render/rtx/bonus_lighting.py and bonus_shaders.py. The shell and emblem share a coating normal derived from the pickup's fixed world center and radius. Icon pulsing, rotation, and weapon aspect ratio cannot move or stretch that coating. Weapon portions outside the shell retain their surface and edge lighting without a spherical reflection. Texture-relief and edge probes stay inside their bonus/weapon atlas cell, and derivatives precede alpha discard.

The renderer retains the original bubble/icon draw order, icon animation, spawn fades, expiration blink, and weapon fade. It uploads the bounded weapon lights once per pickup pass and flushes before changing each draw's material/center uniforms; the pool is bounded to 16 entries. The shader is created only when RTX draws an occupied pool, and is released by the world's render resources. Classic uses its original bitmap path. Unlit RTX emblems retain their source colors; the analytical shell retains its studio highlight.

Weapon source profiles

material_lights.py describes finite luminous sources shared by the ground, creature, and bonus passes. The visible projectile still uses its original draw path. MaterialLight.rgb is linear source radiance; illumination_gain controls diffuse spill, artistic edge lighting, and creature transmission, while reflection_gain controls specular catches. A source can therefore be reflected without lighting nearby dirt. source_width describes the reflection footprint in world units, independently of the illumination radius. reflection_radius optionally specifies separate finite reflection support. These are art controls, not calibrated radiometry.

ProjectileEmission profiles evaluate flight and impact state without modifying the simulation:

Family Source and response
Ordinary tracers Sub-unit-width reflected segments; no diffuse spill
Gauss Narrow blue reflection, 12% of the former radiance, further restrained illumination
Ion / Fire Bullets Strong tapered segments, preserving the reference rifle/fire treatment
Plasma Warm atlas colors, distinct core widths and speed-scaled short tails; compact warm impact glow
Spider Plasma / Shrinkifier Flight tint changes to the untinted warm impact artwork
Pulse Green segment across the shot's front edge, its width following the sprite's; pale green across the white impact
Rockets Localized exhaust sources, scaled by rocket type and respecting the glow setting
Detonations Expanding reflection footprint and fading warm radiance
Flamers Particles grouped into 64-unit spatial cells, with bounded accumulated radiance

Ballistic/Gauss optical gain follows normalized life raised to 2.2, so the reflected image fades more like the legacy alpha-blended tracer. This changes neither its original lifetime nor gameplay. Other profiles retain linear lifetime gain; emitter RGB is never gamma-decoded again in a material shader. Muzzle lights follow the visible flash's suppression flags and warm artwork, with restrained spill for ordinary weapons. Blade, Plague, and bubbles do not gain luminous sources.

Illumination and reflection independently select at most 28 sources by radiance, response gain, and distance from the view center: to a segment's energy centroid for illumination (so a streak's dark tail at the muzzle does not pull it toward the view) and to the nearest point of the segment for reflection. Their union selects at most 28 too; an entry selected for only one response has the other gain zeroed. Shared sources are not counted twice, and reflection-only tracers do not consume illumination slots. When the two want more together, the union takes their sources rank by rank, each response's strongest first, so a storm of tracers cannot crowd out the lamps, nor the other way round. The shaders' arrays hold 32 entries: the other four are for sources fading out.

One trigger pull's pellets (a shotgun's, or Fire Bullets' converted ones) leave the muzzle together, so shots that share a kind, an owner, a spawn point and the tick they were born are one volley, and its pellets whose heads lie within 28 units of the lead's are one source: their radiance adds, the merged segment runs between their brightness-weighted heads and tails, and it keeps one shot's width. The width is never widened by the volley's spread, because every body's shadow margin follows the widest source. Unrelated shots never merge, however close they pass: spider plasma from different spiders, or one gun's separate trigger pulls. A pellet that strays leaves its volley for good and hands its light over across the same 6 ticks as the budget fades: the volley keeps a fading share of it where it was, while the pellet's own light comes up from the volley's place in the budgets. A volley that fans out keeps its total light, and nothing flips back and forth. Merging matters for cost: with it off, Fire Bullets crowds took up to twice as long on the ground.

Every source has an ident that names it from frame to frame (its kind and slot; an explosion's spawn position, since the effect list reorders). Each response fades a source across its budget's edge over 6 ticks of the run's clock instead of popping it: one leaving the budget dims out while the one taking its place brightens. A source never seen before starts at its target, so a new shot lights at once, and a rewind or a gap over a second starts the fades over. While many sources fade at once the union can outgrow 32 entries; the selected sources keep theirs, and of the ones fading out, those delivering the least light (both responses' faded scores summed) give way. Holding every fading source's entry instead, so that a new one waited for a free entry, was tried: in late Survival the entries were full on a tenth of the ticks, and the shots waiting there lost far more light than the cut ever does. The fading sources it drops are dim by then; in long late runs none carried 5% of its response's light.

Ground roughness broadens the explicit source footprint. Creature diffuse dome shading, the artistic silhouette rim, and thin-part transmission follow illumination gain and reach, while specular catches follow reflection gain and width. Bonus coat reflections and foil sheen likewise remain independent of diffuse and artificial edge/back-wall lighting. Material-specific approximations remain: these shaders are not reflections of arbitrary non-emissive objects.

GPU tests exercise reflection-only sources beyond illumination reach, independent gain controls, source footprint changes, alpha preservation, and the upload bound. Transmission comparisons against a zero-transmission shader verify that reflection-only sources retain specular catches without lighting thin limbs. CPU tests cover independent selection budgets, union masking, finite-segment ranking, volley merging, the budget fades, family colors/geometry, optical fade, muzzle flags, expanding detonations, and flame grouping. Flame cell transitions and EDR remain art-tuning cases; the daylight baseline and classic renderer are unchanged.

Ground reflection occlusion

Ground direct light and reflections now share a coarse, color-independent visibility mask for each selected source. Ambient daylight and sky reflection remain independent of it. Reflection-only tracers get masks even with zero illumination gain. This is an intentional visual approximation in the experimental RTX renderer: the direct-light visibility also attenuates the reflection, rather than tracing the displaced reflected-source point.

Coarse visibility atlas

OpenGL, WebGPU and Python/Raylib use the same ground.frag visibility pass and ground_visibility.glsl lookup. The atlas uses four world units per texel, independent of display resolution. Its origin snaps to that world grid with a margin around the visible rectangle; its dimensions stay fixed during camera movement. All 32 canonical source IDs fit in eight RGBA groups, arranged 4×2. Every source keeps its original index (group = i / 4, lane = i % 4), including point, segment, ring, beam and reflection-only sources.

Each frame clears the atlas white. A group's draw is scissored to its sources' combined support, including reflection reach, ring radius and extended beam length. Points and rings use a nearest-emitter ray. Beams retain analytic blocker intervals along their emitting length, two transverse taps and eight energy-weighted visibility bins, preserving directional penumbrae. The shader gathers ordinary body lists from cells intersecting the receiver-to-beam triangle; repeated body entries combine by minimum visibility. A center-ray crowd estimate attenuates the whole beam, independently of body-list order. The full-resolution material pass samples one filtered RGBA value per four sources and applies its lanes to their linear direct/reflected radiance. It binds no body-grid texture and contains no body-grid traversal. Half-texel clamps isolate neighboring groups. No radiance, gamma, exposure or tone mapping is stored in the mask.

The native and WebGPU clients prepare masks after scene/body inputs arrive and before opening the HDR world pass. A late input import invalidates the masks and rebuilds them before the next ground draw. Python prepares them internally in material_scope, using the supplied view size or the full terrain for diagnostic callers. Zero sources, no bodies or disabled shadows select an unshadowed mask; source-count changes clear unused groups, and RTX toggles release the atlas.

Coarse filtering softens small details, and reflections can have displaced occlusion because they share the direct-light mask. Beam visibility retains its finite extent and directional penumbra: collapsing it to one nearest point loses that characteristic. The renderer owns this look; matching every previous pixel is not a constraint. Camera stability, finite values, readable materials and measured replay performance remain the checks for further changes.

The five standard creature species use one rotated, size-scaled ellipsoid each, fitted to the combined planar bounds of their former two body parts and retaining the maximum part height. The single convex mass avoids forked body silhouettes while preserving elongated shapes. Legs, arms and tails are absent; dying bodies retain the collapse and fade described below. Their opacity follows the creature tint. A living player is one torso ellipsoid facing its aim, wider across the shoulders than deep (PLAYER_BODY). A bonus shell is the dome bonus.frag draws, with the shell's own fade-in and expiry blink as its opacity. The volumes extend slightly below the ground plane so their bases meet the surface, rather than balancing a full ellipsoid on a single point. These are stable body approximations, not frame-by-frame silhouettes or anatomical geometry.

A CPU-built spatial grid retains every body near the selected reflection or illumination sources, in 64-unit cells. Finer cells were measured slower in late crowds (more cells walked for the same padded bodies), and coarser ones faster only by turning more cells into crowds. Source-support cells are marked once, avoiding a body-by-source culling loop. A growing, renderer-owned RGBA32F texture holds cell lists and body transforms; no nearest-N blocker cap causes shadows to switch between creatures in a crowd. The shader traverses only cells crossed by the receiver-to-source segment, rejects nearby cell members with a conservative planar bound, and exits when visibility is effectively zero. Ground traversal occurs only in the coarse atlas. The grid includes the selected source endpoints outside the arena, and expanded cell bounds account for the finite-source filter. Each cell has one ordinary point-ray list. Beam fans gather from these same lists, with no secondary packing or range metadata. Header xy holds the list offset/count or crowd height/density; zw is reserved and zero. The beam head halo still extends source support for culling. When shadows are off, the native builder returns an empty descriptor before collecting bodies or inspecting lights; the hosts skip the empty upload.

Visibility uses closest approach to each ellipsoid in its local coordinates, with a smooth transition whose width grows toward the finite source. Wider sources therefore produce softer edges, up to PENUMBRA_WIDTH (8 units): an explosion's fireball or the Radioactive aura casts the edge of an 8-unit source, and the grid pads its lists for no wider. Their own widths padded every body by up to three times as much, the costliest part of shading late crowds. A body does not shadow a light inside or against it (a muzzle flash in a packed crowd, a beam through a creature): its occlusion fades out as the source comes within half a radius of its surface, in the body's normalized coordinates, instead of cutting the light's pool along a straight line through the source. This is an analytic penumbra approximation, not multi-sample area-light integration; overlapping partially opaque volumes combine by strongest occlusion, avoiding seams between creatures. The visibility test does not add ambient darkness, lengthen shadows artificially, or lower the emitter for the occlusion test. Large source widths and heavily animated silhouettes remain approximations. Ground direct lighting and reflections share the atlas; entity lighting uses these volumes and the grid with its own receiver/source segments.

Crowd cells

Monsters don't collide, so a swarm piles hundreds of bodies into one cell, and a ray used to test each of them in every cell it crossed: a late Plasma Shotgun crowd took over a second of GPU time per frame in the native client. A cell where more than 16 bodies stand (CROWD_BODIES; a body stands in every cell its planar extent overlaps) holds a crowd instead of its list: the opacity-weighted footprint of what stands there, as a share of the cell's area (each body's footprint shared among its cells by overlap), and its footprint-weighted top height. A ray crosses a crowd cell as an absorbing medium, exp(-3 × cover × length crossed in cells), over the part of its path below that height; a body's own cell does not shade it. A body centered in a crowd is listed in the cells around it only where it stands, without its soft padding, so a crowd's neighbors keep short exact lists. Beam fans can encounter a sparse body in several cells; repeating its minimum occlusion does not deepen its shadow. The cost no longer grows with the crowd (that frame dropped to about 0.2 s, most of it the original's own sprite fill), and packed bodies' shadows merge into one dark mass anyway. Sparse cells keep exact per-body shadows.

Weapon illumination and soft body shadows

RTX keeps readable daylight fill (ground 0.50 + 0.12 * daylight_ndl, living sprites 0.60, in linear RGB) while Ion, Fire Bullets and plasma add stronger localized illumination. This is an art-directed lighting balance, not exposure adaptation or a night mode. Blood absorption, aging, roughness and substrate/fluid normals are unchanged. Overlapping emitters add independently. Fire Bullet source gain is 4; the ground-only multiplier below applies on top of this shared source gain without increasing creature, bonus or reflection brightness.

lighting_shaders.py shares source placement and visibility between ground and creatures. Every receiver weights a finite segment by one profile (source_geometry.glsl): radiance ramps linearly from the light's tail value at its tail to 1 at its head. Ion and Fire Bullet streaks use a tail of 0, as their stamps fade in from nothing; other segments keep 0.18, and ion arcs are uniform (1). Ground reflections, creature and bonus highlights, and smoke scattering weight a segment by this ramp along its axis. Illumination of the ground, bodies and players comes from its energy centroid, (1 - tail) / (6 (1 + tail)) lengths ahead of the segment's center (a sixth of a streak). The source width supplies analytic soft edges. Fixed source positions avoid receiver-dependent shadow sliding, but this is not integration over the whole beam: it cannot reproduce every penumbra from the sixteen-sample lookdev study. One, two and four samples were measured before choosing this approximation. Ion and Fire Bullets use a coherent 24-unit source height for illumination and reflections, so their reflections move closer to the visible streak than before. Ordinary tracers remain reflection-only, and Gauss stays restrained.

The ground pass builds and uploads the spatial index once; the following creature pass reuses it. Ground rays start at the surface; the artistic sprite lighting receiver is 8 units high. A creature's visibility is walked at its quad's four corners in the vertex stage (creature_visibility.glsl), four lights a vec4, and creature.frag interpolates it across the body: a body pixel no longer walks the grid once per light, which a Nuke over a late crowd made the costliest pass of the frame. Shadows across a creature become a gradient between its corners; thin limbs lose the sharp edge of a neighbor's shadow. Each quad carries its center (the client's hosts average its four vertices, quad_centers; the Python port passes it in the vertex normal), and the corners exclude proxies carrying that owner center. A corner skips a light only when the light's reach misses the whole quad: past it alone, the corner still traces, since the pixels inside the reach interpolate it and a corner snapping to unshadowed would pop as the light moves. A 0.02-world-unit tolerance prevents precision speckles at fractional zoom without requiring per-quad uniforms or breaking batches. Exactly coincident creatures share exclusion; this is a documented limitation of the batch-preserving owner representation. Tests retain blocking from a distinct owner only 0.1 units away, from the corners whose rays cross it.

The shadows setting disables ground direct and reflection masks, creature visibility, and native contact shadows. A blocked emitter does not subtract ambient or other emitters' light. Bonuses still receive their existing analytical lighting without these cast shadows; players and overlays remain on their original paths (both cast body shadows, neither receives them). No flashlight, diffuse corpse occluders, individual legs, smoke or new normal maps are introduced.

The energetic-source policy is explicit (broad_illumination), packed in the otherwise unused ground source-direction Z lane. Only Ion, Fire Bullets, normal plasma, energetic muzzle flashes, flame groups, rocket exhaust and detonations use the broader grazing response. Other sources retain 0.22 * NdotL and their original relief bounds; changing this ground response does not rescale creature or bonus gains.

Warm native OpenGL completion measurements on Apple M1 Pro (OpenGL 4.1 Metal - 90.5), at fixed replay states (world drawing, including CPU submission, body-grid construction and glFinish completion; simulation, HUD and UI excluded):

Scene / target Before Initial candidate Initial candidate, shadows off
Ion Rifle, 67 living creatures, 1600×1000 5.61 ms 7.80 ms 4.86 ms
Synthetic 384 creatures / 16 bright sources, 1600×1000 12.87 ms 32.75 ms 13.88 ms

Thirty measured frames followed eight warm frames per case. The shadows-off setting also disables the existing sprite contact-shadow pass. These are frame submission/completion timings, not isolated GPU timestamps or a whole-game FPS guarantee. The dense case is deliberately severe and its roughly 20 ms added cost is substantial. At 960×600, one/two/four illumination samples cost 18.25/26.24/42.13 ms in that synthetic case; the shipped single centroid avoids the extra integration cost. The corresponding earlier before measurement was 12.32 ms.

Earlier review follow-ups (before the coarse ground atlas) normalized degenerate source axes consistently, disabled nonpositive reflection reaches before evaluating smoothstep, and avoided empty grid traversal/uniform-array uploads. Those measurements below describe the older per-pixel ground path. The coarse atlas now gates all positive ground contributions with the same scalar visibility, independent of their color/gain; shadows-off no longer builds ground reflection masks. Point-source creature reflections and diffuse lighting still reuse their visibility result.

A matched follow-up run with the same hardware, resolution and warmup measured:

1600×1000 scene Before review fixes (67af1231e) After review fixes After, shadows off
Ion Rifle replay 5.92 ms 7.05 ms 5.69 ms
384 creatures / 16 finite beam sources 30.95 ms 29.94 ms 12.53 ms
384 creatures / 16 point sources 31.45 ms 27.31 ms 13.13 ms

The point-source stress case improved by 13%, while this separate-process Ion run regressed by 19% (p10/p90: 4.41/6.75 ms before, 5.80/9.88 ms after). A bounded follow-up interleaved the old, reviewed, and reviewed-without-diffuse-gates shaders in ABC/CBA order twice, with the same current CPU renderer. Each of the four blocks per variant used 10 warm and 20 measured frames. Pooled Ion medians were 6.01/5.80/6.19 ms respectively; p90 values were 9.52/9.56/9.65 ms. The slowdown did not persist under interleaving, but this does not establish a general typical-scene speedup. These small optimizations do not remove the substantial dense-scene cost. Before/after SDR captures differed by at most one 8-bit code value in at most 175 of 4.8 million RGB channels; all body profiles and artistic lighting settings stayed fixed.

Sharpshooter sight reflection

The native red sight contributes a narrow reflection source for each living Sharpshooter player. It uses the same stored endpoints as the visible quad, including the offset near the gun. Its axis points back toward the gun so the reflection fades to black at the distant end, matching the visible sight rather than a projectile's bright nose. Its art-directed red radiance is 1.3 with a 1.2-world-unit source width, making the glint readable over red blood without changing the native sight artwork. It adds no diffuse illumination. Wet blood, ice and other reflective materials use their existing roughness, freshness and occlusion response. Bonus orbs receive the same source through their curved resin-coat reflection; the aiming cursor remains a presentation overlay.

Live ground diagnostics

In RTX mode, F6 cycles ground and smoke diagnostic views; Shift+F6 cycles backward. This works during gameplay and in the standalone replay viewer, including while paused. Return to shaded for the normal scene. The in-game console also accepts rtxdebug <name>: shaded, substrate-normal, fluid-normal, freshness, blood-depth, reflections, direct, direct-unshadowed, visibility, albedo, roughness, height, materials, ambient, no-direct, smoke-density, smoke-heat, smoke-flow, or smoke-light.

The client has the same views, keys and console command (game/rtx.cpp): the module sends the view with the frame's scene, and the host keeps only the ground's shade, or the smoke's, of a map's world and shows it without the tonemap; the aim overlay, which a tick draws, stays. A view picked in a paused replay draws the world again between ticks, without the HUD until the next tick. The web page keeps F6 from the browser.

These are display-only selections: they survive replay seeks, do not affect the simulation/recording, and have no effect in classic mode. Ground maps hide live sprites, particles and aim overlays; the HUD and playback controls remain usable. Raw normals/material channels bypass the world HDR shoulder and game gamma multiplier. Normals encode XYZ as normal * 0.5 + 0.5. Radiance views use a fixed c/(1+c) mapping followed by gamma encoding, independent of display EDR headroom. Explicit material_scope(debug=...) probes retain linear radiance for GPU tests.

direct includes dynamic diffuse lighting after creature shadows; direct-unshadowed evaluates the same material/light response before occlusion. visibility is their linear luminance ratio across all selected lights: white is unblocked, black is fully blocked, and blue indicates no direct illumination. It is not a separate geometric mask or a measure of ambient light. reflections shows dynamic reflections only; no-direct is a matched scene comparison: it removes only dynamic ground diffuse lighting, preserving entities, aim overlays, reflections and the ordinary HDR shoulder/game gamma. Unlike the isolated maps, this can be compared directly with shaded. albedo shows the substrate before blood absorption. materials shows blood in red, corpse coverage in green, brass in blue, and ice in cyan.

The smoke views draw the fluid alone over black, through the same reduced-resolution composite as the scene. smoke-density is the column's opacity at the field's fade: black is clear, white is opaque. smoke-light is the weapon, exhaust and sight light the smoke scatters, without its sky fill, mapped like the other radiance views. smoke-heat and smoke-flow read the raw simulation state across the whole domain, including air that has lost its smoke: heat ramps black, red, yellow, white up to the temperature cap that raises and thickens the column; flow hue is the planar velocity's direction and brightness is 1 - exp(-speed / 60) for speed in world units per second. With no active field there is nothing to show; the solver does not run without a medium.

Explosion emission and pressure fronts

Nuke and Final Revenge use generic explosion effects rather than secondary DETONATION projectiles. Each spawn_explosion_burst now records one render-only explosion event, independent of the one/three/four atlas pieces selected by the detail preset. It uses no additional RNG and does not change the native effect entries, simulation damage, or Classic drawing. Rocket-impact bursts opt out of the additional central light because their secondary detonation already owns it.

The source illuminates terrain and creatures and reflects from wet materials. RTX archives the actual fireball sprites chosen by the native spawner, without extra RNG or additional pieces. It samples their position, scale, rotation and alpha at half age, extending their visible 0.7-second lifetime to 1.4 seconds. Native effect slots still expire normally; flash timing stays unchanged. RTX smoke persists in the presentation fields described below. Classic draws the original slots at normal speed. Rocket bursts retain their native fireball timing as well as their existing light.

The emitter rises from 12 to 80 world units (times blast scale) with a smoothstep over 0.8 seconds. This height is art-directed: the sprites have no native Z data. Source width follows 35% of the growing, drifting fireball sprite bounds, clamped to 12–90 units times blast scale. A low early source gives long shadows; rising height shortens them and the growing source softens them. Radius stays at 330 times blast scale. The linear-radiance envelope is 0.2 * exp(-t / 0.08) + 0.8 * min(1, max(0, 1 - t / 1.4) / 0.75)^1.1: a brief flash, a sustained bright phase through 0.35 seconds, then a fade with visible fireball alpha. RGB is (1.8, 0.92, 0.3) times this envelope; illumination/reflection gains are 6/1.2. These values describe visual tuning, not calibrated nuclear radiometry. Missing fireball sprites do not create a lingering invisible lamp.

In RTX, the explosion-owned atlas ring becomes a warm, translucent shell plus a thin pressure distortion. Freeze, shield and other rings keep their native drawing. The shared render-only profile starts after 0.05 seconds and lasts 0.65 seconds: radius = (30 + 650 * elapsed) * blast_scale, with full strength through elapsed 0.28 seconds, then a smoothstep fade. Shell alpha is 0.36 * strength * min(1, (100 / unscaled_radius)^0.65) using the original RING texture tinted (190, 163, 122). A narrow compression/rarefaction band displaces the world by up to 4.5 pixels times strength, with a transparent interior. A separate RGBA16F target avoids texture feedback, before display tone mapping and before HUD/UI.

A distinct annular source follows this shell: height 16, source width 8, illumination support 85 and reflection support 100 (all times blast scale), illumination/reflection gains 1.8/0.65, and RGB (0.60, 0.39, 0.18) multiplied by strength * sqrt(min(1, 100 / unscaled_radius)). It lights terrain, creatures and bonus shells/icons, and reflects from wet materials. The pressure wave itself is not physically emissive: this warm source is an artistic representation of the visible blast shell. Rocket bursts use it too, without duplicating their central secondary-detonation source.

MaterialLight.ring_radius explicitly describes the annulus; segment length is independent. Shaders choose the nearest ring point for illumination and the nearest ring point to the reflected eye ray for ground/bonus reflections. This is a continuous-shape approximation, not integration over the full circumference. Source ranking measures distance to the circumference; occlusion bounds cover the entire ring plus its support, including blockers inside the circle.

The extra target and shader allocate lazily; frames with no active front use the original tonemap path. One fullscreen pass handles up to 16 simultaneous fronts. Explosion metadata is bounded to 128 events, expires after 1.4 seconds (0.7 for rocket bursts) and clears with the effect pool. Freeing a native slot also releases its visual owner, including overflow slots, so slot reuse cannot hide a later sprite. The shell remembers successful native allocation, then follows its own bounded event age; a reused slot cannot change its geometry or restart it. Archived fireball parameters stay separate from the reusable native slot.

Dying creature casters

A killing hit does not remove the visible body immediately. Dynamic illumination and reflection proxies follow the native death slide: current position, rotation and size remain authoritative, while height falls by smoothstep(0, 16, death_timer). Opacity fades over the final quarter of that collapse, before the corpse is baked at timer zero. A newly killed body therefore keeps its shadow; the shadow shortens and fades as it settles, rather than blinking off at HP zero. The later fading corpse sprite has no elevated proxy above its baked ground material. This uses existing simulation state only and does not alter death movement, damage, corpse timing, RNG or Classic rendering.

Extended beam illumination

Python RTX lights the ground from Ion Rifle, Ion Minigun, Ion Cannon and Fire Bullets streaks as one extended source each. Ion direct illumination uses a 4x gain and a maximum 25% local ambient reduction; Fire uses a 3x ground gain and a maximum 15% local ambient reduction. These are presentation settings: simulation and classic rendering are unaffected. No separate beam console command or replay flag is required:

uv run crimson replay play 18-survival.crd --rtx

One extended source

The source follows the authored beam's stamp spacing, scale, brighter head and current length. Its stamps weigh their position over the length, so along the beam they form a ramp from the tail to the head; the head adds its own brighter stamp, a uniform stretch of the stamp's equivalent half-width around the head end. Each stamp holds its radius squared times the same projected mass, which sets the head's share of the energy (beam_shape): all of a shot just fired, less as its trail grows. The weights sum to the existing light budget; the visible beam art is unchanged.

The falloff, (1 - d/R)^2 of the planar distance, integrates in closed form against a linear density: with the receiver's foot on the axis and its distance b from it, the primitives of 1, u and u^2 times the falloff need only square roots and asinh. The ground takes the energy and its centroid from the ramp and the head pieces; Lambert, relief and the blood's transmittance are evaluated toward that centroid. Against the energy of every authored stamp, summed, this stays within a few percent (up to 8% for a short Ion Cannon shot, whose head is wide), where the three sections it replaced were up to a quarter off for long beams.

Beam energy and its shading centroid use this analytic integral. The coarse atlas preserves the analytic per-body projected blocker intervals, two transverse taps, and eight longitudinal visibility bins weighted by their light energy. beam_visibility.glsl gathers blockers from ordinary grid cells intersecting the beam fan instead of requiring CPU-padded beam lists. Grid preparation includes the extended head support so those blockers remain available. Only the atlas runs this solver; full-resolution material shading reads the resulting mask.

Beams only add light. They used to dim the ambient within 300 units of the beam (up to 25% for ion, 15% for Fire Bullets), regardless of visibility. Past the beam's lighting radius that left a ring darker than plain ground. The golden scenes measured that dip's effect at under 4 levels for 99% of pixels, so it was removed: shadows alone now give the contrast. Use F6 direct, direct-unshadowed, visibility, and ambient maps to inspect the beam's light.

Cost and visual limits

The ground's expensive visibility walks now run only on the coarse world grid, for all selected source families. The material pass retains its full-resolution blood normals, absorption and reflection footprint. Dense scenes benefit most; quiet scenes still pay for the material work and any small atlas preparation. GPU timer queries can perturb the macOS driver, so use separate queries-off replay runs for wall-time acceptance and timer-enabled runs to diagnose the ground-vis/ground-shade split. Reflection shadows intentionally differ from independent reflected-ray traversal; the beam's analytic directional penumbra remains.

Ion impact arcs

Ion shots also register the visible post-impact connections as RTX sources, and glow where they meet creatures. Drawing and lighting share the same target query: only the impact/fade phase, the original slot-zero exclusion and collidable lifecycle, weapon scale, and Ion Gun Master reach apply. Fire Bullets do not chain.

Each connection contributes a finite segment source, fading with the visible arc. Its contact is always a disc, so it is not a light: an additive ground glow (contact_glow.py, contact_glow.frag) in the arc's color, a 60-unit disc drawn before the creatures, leaves the light budget to the arcs and everything else when an ion crowd would otherwise fill it. Arc segments are uniform (a tail of 1), so their reflections are even along the arc and their illumination centroid is its middle. The new sources use restrained independent gains, without the main Ion beam's 4x ground gain. At most four connections per impact contribute sources, ranked by segment distance to the view center (with endpoint distance as a tie-breaker). The artwork draws its arcs as Classic does, at most two per shot into one small cell (rendering pipeline). This bounds source preparation as well as brightness in dense crowds. This is an artistic brightness cap, not a physical energy simulation. Ordinary global source ranking and its 28-source budgets still apply.

The original beam stays lit during its fade. Arc sources add illumination and reflections through the shared ground, creature, and bonus light list. Classic artwork, damage, target selection, and replay state are unchanged.

Persistent fluid smoke

GPU fluid smoke is on by default in RTX. In the game, rtxsmoke off immediately clears the fluid and restores native smoke sprites; rtxsmoke on re-enables it. Standalone playback accepts crimson replay play <replay.crd> --rtx --no-rtx-smoke to disable it. --rtx-smoke explicitly enables both fluid smoke and RTX. The console choice belongs to the current game/replay world and is not saved to config. The existing Smoke display setting still controls whether the fluid runs. Classic always uses its native smoke path.

When enabled, RTX replaces native smoke puffs with a persistent simulated density field. Rocket exhaust, rocket impacts, and other native smoke emissions feed the same system. Nuke pickup explosion puffs request 15 seconds of retention; ordinary smoke requests 10 seconds. Density decays throughout that time, with an additional soft fade over the final two seconds. These are maximum retention windows, not a promise that every small puff remains visible for that long.

Implementation: render/rtx/gpu_smoke.py, gpu_smoke_shaders.py, smoke.py, smoke_shaders.py, and sim/smoke_events.py. Simulation state stays in RGBA16F render targets: density, temperature, and planar velocity. OpenGL 3.3 fragment passes perform advection, pressure projection, and source deposition. This needs neither compute shaders nor a per-frame CPU grid upload/readback. Python retains immutable emission batches and presentation clocks. smoke_simulation.py remains an explicit CPU reference for offline comparisons; it is not a runtime fallback. Failure to initialize the GPU solver or display restores native smoke.

Native events and lifecycle

Each deterministic tick captures immutable copies of actual SpriteEffectPool smoke spawns and EffectPool explosion puffs, including command-triggered spawns. No additional gameplay random numbers are consumed. Native delays an explosion's puff sprites behind its fireball; their smoke deposits at once, so it forms with the fireball and its blast front. Native expansion contributes planar velocity. Nuke retention is explicitly marked around the Nuke's and Final Revenge's bursts, so ordinary rocket explosion puffs do not inherit the longer lifetime. Large general-effect puffs weight their source strength by native tint luminance: their dark tint must not become an opaque gray disc that obscures the finer sprite smoke. This is an artistic conversion into a shared scattering medium, not a physical mass calibration. The explicit nuke presentation profile increases that dark-puff strength eightfold, distributes it into irregular overlapping billows with local curling motion, and lowers density decay from 0.20/s to 0.12/s so the extended aftermath remains visible. The shared domain gradually returns to the ordinary decay rate after receiving a nuke deposit; this does not add mass to smoke already there. Capture bookkeeping lives outside native state and checkpoint serialization.

Presentation consumes every tick's batch, including empty batches, using its simulation delta. Fixed 60 Hz substeps accumulate fractional slow-motion time; pause and extra draws do not advance the fields. Forward replay playback uses the same presentation handoff. Level/reset/session replacement, RTX mode changes, and disabling the smoke setting clear fields and queued deposits. Re-enabling RTX starts with new emissions. Native smoke sprites and general explosion puffs are not drawn while fluid smoke is enabled, including when the solver is empty or at its allocation cap. Classic retains the original sprites. Checkpoint restoration and explicit replay seeking clear this transient history instead of storing it in the deterministic checkpoint. Undrawn seek ticks do not leave a GPU catch-up queue; normal playback and diagnostic views retain every tick.

One shared 320 × 320 domain covers the arena plus a 128-world-unit guard band on each side. All trails exchange flow and share light extinction; there are no internal patch walls or separate alpha layers. Sources rasterize only their bounded footprint. Sampling outside the domain returns zero; open pressure boundaries and an absorbing outer band allow outflow instead of reflecting it. The domain is fixed, so a moving camera cannot relocate existing smoke and no GPU occupancy readback is needed to resize it.

Empty scenes perform no fluid steps. Domains retire at the end of their retention window. New deposits extend remaining retention, baking any existing final fade into density and temperature so old smoke is not restored. Nuke decay relaxes back toward the ordinary rate with a roughly 14-second time constant. Sources outside the padded arena do not allocate new simulation domains.

Motion and light

The GPU solver uses semi-Lagrangian advection for planar velocity, 12 pressure-relaxation iterations, vorticity confinement, and damping. Density and temperature use conservative upwind face fluxes. A donor outflow limiter keeps these fields nonnegative even when an impulse moves air farther than one cell per step; at such speeds it trades transport speed for stability. Shared face fluxes preserve column mass apart from source deposits, decay, open-boundary outflow and RGBA16F rounding. This prevents an expanding blast from multiplying smoke. The CPU reference's MacCormack scalar correction and finer growing grid are not pixel-equivalent to this bounded GPU approximation. Sources inject presentation motion and expansion; a small fixed planar breeze supplies background drift. RTX muzzle puffs use the firing direction instead of the native sideways velocity, with a resolved elongated footprint and a short jet with paired vortices. Rocket exhaust moves backward along the flight direction instead of inheriting the native cosine-for-both-components drift. A moving or jetting source deposits a little ahead of itself; a still one, as an explosion's puffs, where it is. These overrides affect only copied smoke events; native sprite values, RNG, collision, damage and replay checkpoints are unchanged.

Projectiles deposit velocity-only wakes along their actual swept paths. A shot drags the air along each stretch of its path once, however long the tick, over a width of a few cells, so its vortex pair resolves. Ion, fire, plasma and rockets have stronger wakes than ordinary bullets. Muzzle jets and wakes enter before pressure projection; explosion fronts deliver a short expanding radial impulse after projection so the incompressible solve does not erase their expansion. Neither impulse creates smoke or heat. All rocket detonation paths and nuke bursts contribute blast motion, including low-detail explosions without smoke puffs. No visible medium means no additional fluid simulation is needed. Per-tick GPU impulses are capped at 64, prioritizing blasts and muzzle jets before wakes, and ticks that share a solver step in slow motion share a budget of twice that; at most 16 blast fronts persist. New forces reuse the existing solver and pressure iterations.

Bodies move the air too. Each tick, every creature, corpse and living player that moved drags the air under it toward its own velocity (smoke_events.py's body_wakes, the solver's drag pass, mode 6 of smoke_solver.frag), over a radius about a third of its size. Air near several bodies eases toward their weighted mean velocity, so the drag never pushes it faster than the bodies move, however long a crowd walks through it; pressure projection then turns the drag into flow around them, which, as around any obstacle, runs up to about a third faster at their sides. A step longer than 30 units is a spawn, a reused slot or a teleport, not motion. Bodies have their own budget of 64 a tick, fastest and largest first, so a swarm never crowds out blasts, muzzle jets or shots; a step drags each body once, where the latest tick left it. Trails keep their shape; smoke gathers where a crowd converges and is carried along with it. The drag is one full-field pass per step.

Rocket exhaust and impact deposits use 40% of the old source strength, and ordinary general-effect explosion puffs use 60%. The nuke profile retains its existing strength and decay. RTX rocket explosions omit the visible pressure shell, shell light and screen distortion; their central explosion lighting and their motion through existing smoke remain. Classic explosion rings are unchanged. Temperature controls an assumed vertical density profile in the display shader. This is a shallow 2D flow approximation with a 2.5D display, not a simulated 3D volume or vertical buoyancy model.

Sources deposit heat by profile (smoke_constants.py's SOURCE_HEAT, per unit of smoke): explosion puffs and the nuke 2.5 times a muzzle's, rockets twice. Heat cools faster than smoke thins, so heat per unit of smoke is the smoke's age, and fresh smoke hotter than a muzzle's glows (smoke.frag's ember): yellow-orange from an explosion's first instant, through deep red, to the ordinary gray within about a second. Rocket exhaust trails glow for their first instants too. Muzzle smoke and old smoke never glow, and the glow lights nothing else. Hotter explosion smoke also takes a slightly taller, thicker column.

Flames leave soot. Each flamethrower, Blow Torch or HR Flamer particle that burns out drops a small, faint puff where it died, drifting on at a fraction of the flame's speed. Its heat is just above a muzzle's, so it glows dimly for a moment before it turns gray. A creature burning in the flame smokes as flame soot too, never as an explosion's glowing cloud. The flame sprites themselves are unchanged, and Bubblegun particles leave nothing.

The display integrates 14 vertical samples and six transmittance samples per light into a reusable RGBA16F viewport buffer capped at 640 pixels on its longest axis. Premultiplied reconstruction preserves HDR radiance and translucent edges; the world itself stays at full output resolution. It uses the shared native weapon sources, including rocket exhaust, Fire/Ion beams, and explosion lights: of those reaching the domain, the 16 that scatter most. Fractional source gains are preserved; gains above four are capped for smoke response. A segment scatters by the shared beam profile, so a streak's tail lights the cloud less than its head: the smoke sees that radiance ramp averaged over as much of the segment as it is far from it. Smoke beside a long beam follows the ramp, and smoke behind a dark-tailed shot still sees the shot, with no line across the cloud at the muzzle. Up to four living players' native Sharpshooter sight segments scatter as narrow red beams only inside density, rather than broadly illuminating the whole cloud. The ordinary sight artwork remains unchanged.

Smoke composites after native sprites, outside their alpha-test shader, inside the HDR world and pressure-front composition. Ground diagnostic maps hide the composite while continuing to drain queued fluid updates; the smoke views show it alone. The renderer approximates single scattering and self-shadowing within the shared domain; the assumed slab does not resolve creature geometry. Broad sky extinction gives billows some form after local emitters fade: where neighboring smoke blocks the sky, thick smoke shades well below its sunlit gray. Rocket exhaust uses a separate sixfold scattering response, without increasing its ground or creature lighting. Simulation cost scales with grid area and pass count; display cost scales with shaded pixels and lights. Density columns are sampled once and reused at every integration height, preserving height-dependent optical depth. GPU timings and full-frame timings must be measured separately; reducing CPU simulation time does not imply the rest of the RTX frame is free. Shader/solver resources prewarm on the first enabled frame, moving the initialization cost out of the first shot.

A synchronized M1 Pro benchmark of native rocket/nuke inputs and a 99-creature fixture measured 1.6–2.5 ms median simulation and 0.6–0.9 ms smoke display costs. At 1024 × 768, completed world-frame medians were 6.8–11.0 ms, including native simulation updates. At 2048 × 1536 they were 8.6–13.1 ms. The dense high-resolution case reached 22.6 ms at p95 (18.9 ms with native smoke); this is not a universal 60 FPS guarantee. The benchmark excludes HUD, window presentation, audio and video encoding. The console/CLI off switches remain available for slower GPUs.

Perk illumination

RTX Radioactive retains the original additive green aura and its render-clock sine pulse. A 100-unit green material source follows each player, including the native aura's post-death presence. The source uses height 18, width 13, RGB (.10, .70, .07), and illumination/reflection gains 3.8/.8, multiplied by the original aura alpha divided by its mean .4375. It illuminates ground and bodies, reflects in wet materials, and uses the shared creature-volume visibility path. The display shadow setting controls direct shadows as for other material lights. Reflection visibility remains part of the existing reflection model. Radioactive uses the ordinary source budget; it does not alter damage range or pulse timing.

Monster Vision shows living monsters as heat (render/rtx/heat_vision.py). It keeps the native death fade, entity transition alpha and sprite-shadow suppression; classic keeps the original yellow aura texture.

  • Bodies: after each species' body pass, the same sprites draw again with heat_body.frag in the ironbow ramp (ironbow.glsl), indexed by the creature silhouette density: two 5×5 alpha blurs within each atlas cell, made once on the atlas's first heat draw (one texture tap per fragment). Density preserves cool extremities; two soft sprite-local lobes, a fixed abdomen and a head, keep hot spots stable through articulation. The zombie's head follows its walking frames, baked once with OpenCV optical flow (zombie_heat_heads.json) and carried in the tint's green/blue; other species and cells keep it fixed. Bounded detail from the sprite color already fetched with its alpha brings back the artwork's surface features, without extra texture taps. This retains a wide magenta-to-white temperature range without the square-opening thickness proxy's abrupt jumps between animation poses. All species use the same response; transmission lighting retains its separate thickness proxy. The overlay's opacity is Monster Vision's fade times .88, never the creature's tint alpha, so Hidden Evil's near-invisible aliens show in full. Red in the tint carries warmth: the native death fade. While Freeze holds the monsters they show no heat, overlay or footprints, so they read as themselves inside its ice; the heat returns with Freeze's own fade out. Scene transitions fade the overlay's opacity, never its warmth.
  • Trails: a world-space float buffer at half the terrain's resolution. Each drawn frame fades it by the game time since the last (half-life 0.3 s, so slow motion leaves the same trail per distance), then max-blends a soft footprint (45% of the creature's size) at every warm monster. A frame gap over a second, a rewind, or a terrain reset starts it over. Where the aura was drawn, heat_trail.frag adds the buffer under the view to the ground as faint ironbow heat, under every body. Stored heat ignores scene transitions; the composite fades with the scene's entity alpha, like the bodies.

The sensor is selective: fire, explosions and beams keep their own look and do not wash the monsters out. Heat adds no material lights and changes no simulation, RNG or replay state. The native and web clients draw the same heat.