Terrain (rewrite)¶
This page describes how the Python + raylib rewrite models the classic game's
terrain pipeline (see also: docs/crimsonland-exe/terrain.md).
Mental model¶
- The world background covers 1024×1024 logical units. Interactive clients bake it at 2× density (2048×2048 pixels by default).
- In the original exe, it is a render target that gets:
1) procedurally generated once (
terrain_generate) 2) incrementally updated by baking decals (blood/corpses/etc) into the same texture (fx_queue_render) 3) drawn to the screen as one fullscreen quad with UV scrolling based on camera offsets (terrain_render)
Where this lives in the rewrite¶
Generation: src/crimson/sim/terrain_generate.py; drawing: src/grim/terrain_render.py
terrain_generate(rng, slots)andterrain_generate_random(rng, unlock_index)mirror the two native functions. They consume the authoritativecrt_randstream eagerly and return aTerrainSetup: the texture slots plus three stamp layers (grim.terrain_stamps.TerrainLayers). Each stamp is the native pre-scale value: float32 rotation(float)(rand % 314) * 0.01f, and a top-leftrand % 1152 - 64that already includes the overscan, drawn rotation, then y, then x.GroundRenderermaintains an internal RT sized from2048/texture_scale, independent of window DPI.GroundRenderer.schedule_stamps(layers, texture_scale=...)queues drawing a generated setup, andGroundRenderer.process_pending()performs the scheduled RT creation and stamping. It appliesinv_scale, moves the native top-left to raylib's quad center, and never touches an RNG, so drawing or re-applying a setup is free.GroundRenderer.draw_view(camera, screen_w=..., screen_h=..., out_w=..., out_h=...)draws the RT to the screen using UV scrolling.texture_scaleis a terrain-setup input, not a live runtime knob: it is passed with the stamps and only sizes the RT. Existing menu/gameplay grounds keep their RT until terrain is explicitly replaced, and every draw into it reads the scale back from the allocated RT.
A ground only changes when a setup is applied: gameplay and replay playback install PreparedRun.terrain,
menus draw their own terrain_generate_random on the application stream (or keep the gameplay ground
they took over), and the arsenal and lighting debug views apply a detached terrain on each scene reset.
Resetting the world or reopening render resources leaves the ground alone.
Generation scope¶
- The simulation assumes the terrain texture never fails (
terrain_texture_failed == 0), as it assumes audio is on. Natively a failed texture makesterrain_generatebind the descriptor's base texture and return before any stamp draw, whileterrain_generate_randomstill draws its three selector draws and the eligible unlock rolls: a successful roll delegates to that draw-free fallback, and the default branch returns without stamping. A capture from such a run would need that flag recorded in its run metadata. - Native recovery regeneration is not modelled. When Grim sets config var
0x57(texture backup failure, DC-modeWM_PAINT),game_frame_updateregenerates terrain mid-run on the live stream:terrain_generate_random(), or during queststerrain_generate(&quest_meta_table[minor * 10 + major])with minor and major wrapped separately, a swapped index that can read past the 50-entry table (quest 1.6 gives 50). A capture containing it cannot replay exactly. Should the port ever rebuild a lost render target, it would redraw the retained setup rather than draw new terrain. - The console
generateterraincommand natively runsterrain_generate_random()on the live stream. The port keeps the gameplay RNG and the current texture slots, and stamps withterrain_generatefrom a detachedCrandseeded from the gameplay RNG state plus a counter that advances per command. The menu ground regenerates withterrain_generate_randomon the application stream. - Demo/attract terrain (
demo_setup_variant_1/_3and the reset insidedemo_mode_start) is excluded with the rest of attract mode.
Intentional rewrite deviations:
- Procedural terrain stamps use Catmull–Rom reconstruction in premultiplied-alpha space while rotating into the RT. Negative-lobe overshoot is clamped before converting back to straight alpha for the existing blend equations.
- Decals, corpse atlas frames and bilinear-filtered artwork reconstruct in premultiplied-alpha space with four source samples. This prevents invisible RGB from tinting or darkening translucent edges. Source uploads stay RGBA8; no extra source textures are allocated.
- The shared shader is
src/grim/filtering.glsl. Python includes it in artwork shaders; the native/WebGL renderer uses it for linearly filtered source artwork. Cubic reconstruction is restricted to terrain artwork baked into a target. Point sampling, render-target copies, ground presentation, fonts, analytical effects and the alpha cutoff remain unchanged.
Ground dump fixtures (parity test)¶
Ground render-target dumps captured from the original game (with the since-removed Frida tooling, so they cannot be regenerated) serve as fixtures to ensure the rewrite matches within measured image tolerances for the same seed and terrain texture indices.
- Fixtures:
tests/fixtures/ground/ground_dump_*.png+tests/fixtures/ground/ground_dump_cases.json - Test:
tests/render/test_ground_dump_fixtures.py
Run the test:
Notes:
- Requires a display accessible to raylib. On macOS, a sandbox can hide the
active display; run these tests with display access. Linux checks
DISPLAY/WAYLAND_DISPLAY. - Requires game assets at
game_bins/crimsonland/1.9.93-gog/crimson.paq. - The test renders at the capture's pixel dimensions, including on Retina displays. Missing tracked captures fail the test instead of skipping it.
tests/render/test_shader_pixels.pychecks the alpha cutoff, shader cleanup, and full-frame gamma with GPU pixel readback. It needs a display but no game assets or--run-terrainflag.
Decal baking¶
The exe’s “persistent gore” works because it is drawn into the ground render target before terrain is blitted to the backbuffer.
The rewrite exposes the same mechanism via two helpers:
GroundRenderer.bake_decals([...])for generic textured decals (blood, scorch, etc).- Scales positions/sizes by the allocated RT width divided by the terrain width. This includes the RT’s fixed 2× density and stays fixed if the window moves between monitors with different DPI.
- Runs through the terrain alpha-test shim, so low-alpha fringe texels are discarded before blending.
-
Uses bilinear reconstruction in premultiplied-alpha space while baking.
-
GroundRenderer.bake_corpse_decals(bodyset_texture, [...])for corpse sprites (bodyset 4×4 atlas frames). - Uses the same allocated RT scale as generic decals, so DPI changes preserve corpse positions and sizes.
- Implements the two-pass corpse baking:
- a “shadow/darken” pass using
ZERO / ONE_MINUS_SRC_ALPHA - a normal alpha blend color pass
- a “shadow/darken” pass using
- Applies the exe’s small alignment tweaks (
-0.5shift andoffset = terrain_scale/512) and rotation offset (rotation - pi/2). - Uses the same bilinear reconstruction in premultiplied-alpha space.
Blend mode when drawing to screen¶
During terrain generation, stamps are drawn with alpha blending enabled
(SRC_ALPHA / ONE_MINUS_SRC_ALPHA). On an RGBA render target, this affects not
just RGB, but also the alpha channel:
In the original exe, the "ground" render target is typically created in an
XRGB format (no alpha), so this drift never matters. In the rewrite, the RT is
RGBA, so we emulate XRGB more directly by masking out alpha writes while
stamping into the terrain RT:
rl.rl_color_mask(True, True, True, False)
rl.rl_set_blend_factors(rl.RL_SRC_ALPHA, rl.RL_ONE_MINUS_SRC_ALPHA, rl.RL_FUNC_ADD)
rl.begin_blend_mode(rl.BLEND_CUSTOM)
# On some backends, re-apply factors after switching the mode.
rl.rl_set_blend_factors(rl.RL_SRC_ALPHA, rl.RL_ONE_MINUS_SRC_ALPHA, rl.RL_FUNC_ADD)
# ... stamp decals/strokes into the RT ...
rl.end_blend_mode()
rl.rl_color_mask(True, True, True, True)
Additionally, when drawing the terrain RT to the screen, we use a custom blend mode that fully replaces pixels (ignoring source alpha):
rl.rl_set_blend_factors(rl.RL_ONE, rl.RL_ZERO, rl.RL_FUNC_ADD)
rl.begin_blend_mode(rl.BLEND_CUSTOM)
# On some backends, re-apply factors after switching the mode.
rl.rl_set_blend_factors(rl.RL_ONE, rl.RL_ZERO, rl.RL_FUNC_ADD)
# ... draw terrain quad ...
rl.end_blend_mode()
This ensures terrain is always drawn opaque, matching the original game's behavior.
Why this mode:
- It keeps the terrain RT alpha pinned to
255through generation and baking, which matches the XRGB mental model directly. - It is simpler than carrying separate blend-factor branches for alternate alpha behaviors that we do not intend to ship.
Runtime application¶
Simulation collects generic and corpse decals in src/crimson/sim/terrain_fx.py.
The session captures each tick's batch in its presentation plan;
src/crimson/sim/batch_apply.py delivers it to
src/crimson/world/render_resources.py for baking.
WorldRuntime.apply_terrain_setup (src/crimson/world/runtime.py) installs a TerrainSetup and remembers it. GPU calls do not run inside
the authoritative world step. See run startup
for terrain generation and RNG ownership.
The captured fixtures cover specific terrain configurations. Broader weapon, bonus and corpse visual parity still needs corresponding runtime evidence.
Replay memory¶
Both interactive clients retain at most eight full-resolution ground checkpoints. At the default density, their RGBA8 color storage is 128 MiB (eight × 16 MiB), matching 32 old 1× color snapshots. This excludes depth attachments and other render targets; nondefault terrain sizes change the byte cost. Compaction frees old snapshots before allocating the next one, so allocation never briefly needs a ninth copy. Fewer checkpoints can require more decal rebaking on a seek. Keeping copies at 2× avoids progressively blurring baked decals after restoration.
Backend simulation allocates no ground framebuffer. Server replay thumbnails retain their existing 1× software rasterizer; this change does not alter that path.
Filtering cost¶
Measured on Apple M1 Pro, 2026-10-20, with a 2048×2048 target in every variant.
Times include CPU submission and synchronized GPU completion, excluding shader
compilation, asset loading and initial allocation. Each result has four warmups
and 20 samples. These are workload timings, not whole-game FPS or isolated GPU
kernel timings. Raw results are in crimson-core/results/ground_filtering.json.
| Workload | Ordinary bilinear | Associated bilinear | Associated Catmull–Rom |
|---|---|---|---|
| Python Q1 terrain, 1,700 real asset stamps | 3.71 ms | 6.70 ms | 20.37 ms |
| Native renderer, 1,700 synthetic terrain stamps | 13.55 ms | 16.58 ms | 30.09 ms |
| WebGL2 renderer, same synthetic terrain | 12.00 ms | 17.50 ms | 31.00 ms |
Cubic adds about 17–19 ms to these terrain rebuilds. It runs when constructing terrain, not when presenting each frame or baking later blood/corpses. Python's real-asset workload and the C++ synthetic workload have different coverage and submission paths, so their absolute times should not be compared directly.
The separate 500-sprite synthetic workload uses associated bilinear in both new modes: native median 1.12 ms before versus 1.13–1.21 ms after; WebGL2 1.41 ms before versus 1.66–2.13 ms after. Browser scheduling/timer noise is visible even between identical sprite shaders. Each sprite sample averages 16 synchronized frames; these figures do not predict an equivalent whole-game frame-time increase.
Reproduce (GPU/display required):
uv run scripts/bench_ground_filtering.py --paq /path/to/crimson.paq
uv run crimson-core/checks/renderer.py --benchmark baseline
uv run crimson-core/checks/renderer.py --benchmark bilinear
uv run crimson-core/checks/renderer.py --benchmark cubic
# Add --web to build each workload for WebGL2; serve crimson-core/build
# and open renderer-check-{baseline,bilinear,cubic}/index.html.
The Python benchmark interleaves the three modes using Q1 terrain and seed
0xC0FFEE. The native/WebGL benchmark uses deterministic synthetic RGBA artwork,
1,700 rotated overlapping terrain stamps, and a separate 500-quad sprite pass.
It runs the production renderer's pixel regressions before timing.