Rendering and streaming
kerosene-render splits cleanly in two: mesh.rs, lightmap.rs and
camera.rs are pure CPU logic that decide what to draw and can be tested
without a GPU; gpu.rs is the wgpu layer that draws it. The module doc in
crates/kerosene-render/src/lib.rs states the frame in four steps: find the
viewer’s cluster, take its PVS row, frustum-cull the leaves then their
surfaces, draw what is left batched by material.
From BSP to triangles
WorldMesh::build_for(bsp, atlas, keep) in
crates/kerosene-render/src/mesh.rs turns the compiled faces into
WorldVertex { position, normal, uv, lightmap_uv, tangent } plus an index
buffer. Two coordinate conversions happen here and nowhere else:
- Winding order. Faces are stored clockwise seen from the front (the brush-file convention). GPUs treat counter-clockwise as front-facing, so the triangle fan is emitted reversed.
- UVs.
texinfoproduces texels; shaders want them normalised by the texture size, so the atlas and material coordinates are divided at build time.
The tangent is read from the texture projection, not derived from the
triangles. texinfo already stores the world direction u increases along, and
that is the tangent; deriving it from geometry would agree only by
coincidence and light a normal map from the wrong side wherever it did not.
The bitangent’s sign lives in tangent.w, which is what stops mirrored text
from lighting inside out.
Faces are grouped by material first and the groups are sorted, so batch
order and the index buffer are byte-identical between runs. A Batch records
contiguous_range when every surface in it is contiguous in the index buffer,
which lets the whole world draw in as many calls as it has materials rather
than one per face.
Two mappings are built after the surfaces:
leaf_surfaces: Vec<Vec<u32>>— surface indices per BSP leaf, for PVS culling.model_surfaces: Vec<Vec<u32>>andmodel_bounds— surface indices per brush model. This exists because brush entities are compiled as their own models and their leaves are not in the world’s PVS. A leaf walk finds the world and nothing else. A comment inWorldMeshrecords the war story: every brush entity in every map was once built into the mesh and never drawn, and the sample map’s door was simply not there.
What to draw
---
config:
layout: elk
---
flowchart TB
eye(["viewer eye + frustum"]) --> cluster["bsp.point_cluster(eye)"]
cluster --> pvs["bsp.visible_leaves(cluster)<br/>(all leaves if no vis)"]
pvs --> leafloop{"for each leaf"}
leafloop -- "leaf bounds vs frustum" --> leafsurf["leaf_surfaces[leaf]"]
leafloop -- rejected --> leafloop
leafsurf --> surfcheck{"surface bounds vs frustum"}
surfcheck -- yes --> seen["dedupe via seen[]"]
surfcheck -- no --> leafloop
seen --> sort["sort by (material, surface)"]
sort --> draw["Renderer::draw_world"]
models(["brush models"]) --> modelvis["WorldMesh::model_is_visible<br/>frustum only, by posed bounds"]
modelvis --> drawmodel["Renderer::draw_model per model"]
props(["physics props"]) --> proppose["blend previous/current pose<br/>by interpolation_alpha"]
proppose --> drawmodel
classDef proc fill:#FF6D00,color:#fff
classDef data fill:#2962FF,color:#fff
class eye,models,props data
class cluster,pvs,leafsurf,seen,sort,draw,modelvis,drawmodel,proppose proc
WorldMesh::visible_surfaces does the leaf walk: the PVS removes rooms you
cannot see through any opening, the frustum removes what is behind you, and
neither subsumes the other. It sorts the surviving surface indices by
(material, surface) so gpu.rs’s draw loop rebinds a material only when it
actually changes.
Brush models deliberately skip the PVS. model_is_visible culls by frustum
against the posed bounds (the enclosing box of the turned box, because a
frustum test wants an axis-aligned answer). The comment is explicit: a brush
entity could be leaf-tested, but there are a handful of them, they are the
things the player walks up to, and culling one wrongly costs far more than
drawing it.
Interpolation
Engine::interpolated_brush_model_poses(alpha) blends each model’s previous
and current (origin, angles) and rebuilds its Pose around the model’s own
compiled centre. The centre comes from the model’s bounds rather than a
keyvalue — a brush model is built in world coordinates, so there is no other
point that means “spin where you stand”. Collision traces the raw current-tick
pose; only the drawn pose lags by up to one tick, the same as the camera.
Physics props each take a model slot in the host’s draw and draw where their
body is, blended from PhysicsProps::previous_pose. MAX_MODELS slots are
uploaded as a single update_models uniform array with a dynamic offset — see
gpu.rs, ModelUniform.
Lightmaps
crates/kerosene-render/src/lightmap.rs packs every face’s per-luxel grid into
one 2048² atlas (ATLAS_SIZE) so the world draws without a texture bind per
face. The packer is a shelf packer: sort by height, lay pages out in rows. It
is not optimal, but lightmap patches are small and similar-sized, which is
exactly what shelf packing handles well — and being deterministic matters more
than the last few percent of occupancy, because a map that packs differently
between runs invalidates every cached atlas.
PADDING = 1 blank texel around each patch, because bilinear filtering at a
patch’s edge otherwise samples its neighbour and every surface picks up a thin
bleed. AtlasRect::to_uv insets a half-texel so a sample at luxel 0 lands at
the centre of the first texel. overflowed counts faces that did not fit; they
draw unlit and the host warns, suggesting a coarser lightmap scale.
The atlas holds linear light in Rgb9e5Ufloat (ATLAS_FORMAT): three
9-bit mantissas and a shared exponent, four bytes a texel like the RGBA8 it
replaced, and filterable everywhere. It used to be 8-bit, squeezed through a
Reinhard curve on the CPU and then compressed a second time by the shader, so
a lamp and the wall beside it read nearly alike. The codec is in
kerosene_bsp::cubemaps (encode_rgb9e5), shared with the probes.
The atlas is built at unit exposure, and mat_exposure is applied live by the
tone-map pass. Folding exposure into the atlas as well squared it and froze
half of it at load time — a bug the host comment records.
A frame’s passes
---
config:
layout: elk
---
flowchart LR
scene["scene pass<br/>Rgba16Float HDR, MSAA x4<br/>world, brush models, props, lines"] --> resolve["resolve<br/>(end of pass)"]
resolve --> tonemap["tonemap pass<br/>exposure + ACES / Reinhard / clip"]
tonemap --> ui["UI pass<br/>egui, not tone-mapped"]
ui --> present(["swapchain"])
classDef pass fill:#FF6D00,color:#fff
class scene,resolve,tonemap,ui pass
Every scene shader writes linear HDR into HDR_FORMAT and nothing
tone-maps per surface. Renderer::begin_scene_pass returns the pass; with
r_msaa above 1 it draws into a 4x target and resolves into the single-sample
one, discarding the samples (the cheap path on tiled GPUs). A change of sample
count rebuilds the scene pipelines, which is why set_msaa keeps the shader
modules and layouts (SceneShaders). Renderer::tonemap then draws one
fullscreen triangle: exposure, then the mat_tonemap curve. The UI draws over
the result, so the console stays the colour it is. The host brackets the
world, brush models, props and debug lines in push_debug_groups, so a
RenderDoc capture reads as the frame’s structure.
Shading
world.wgsl and model.wgsl share one reflectance model – GGX,
height-correlated Smith, Schlick – with metalness blending F0 from 4% grey to
the base colour. crates/kerosene-render/src/brdf.rs is its CPU mirror, and
its tests are the BRDF’s properties: the distribution projects to one, Fresnel
runs from F0 to white, nothing reflects more than arrives. A change to one side
that is not made to the other is a bug.
A lightmapped surface has no light direction – the bake says how much light
arrived, not from where – so the world does not evaluate a GGX lobe at all.
It uses the split-sum environment term (env_brdf, Karis’s fit) against what
surrounds the surface: its cubemap probe, or, without one, an even glow the
brightness of its own lightmap. Props do have a direction, their fixed key
light, so they get the full lobe as well as the environment term.
Materials carry $metalness and $roughnessfactor as scalars beside the
maps (MaterialUniform); a dielectric with neither a roughness map nor a
factor below one takes no specular path at all, so an albedo-only material
renders exactly as its lightmap says.
Skinning
Studio-model vertices go to the GPU with their four bone indices and weights
(ModelVertex, the same 40 bytes as the file). A second dynamic-offset
uniform holds bone palettes, kerosene_anim::MAX_BONES (128) matrices each:
slot 0 is identities and never rewritten, so every static model binds it and
draws as before; animated models get slots 1 to MAX_SKINNED from
update_palettes. vs_model and vs_shadow_skinned blend the four
matrices. Models have their own pipeline layout (SceneShaders::model_layout,
the world’s plus the bones group), so world geometry is untouched.
kerosene-anim computes the palettes: Skeleton::of derives inverse bind
matrices from the bones’ rest poses, sample interpolates a clip, and
Playback crossfades a change of clip over CROSSFADE. A prop_dynamic
keeps its playback in entity fields in game time; engine::animation reads
them, fires OnAnimationDone when a one-shot ends and returns the prop to its
defaultanim, and the host poses it at Engine::render_time(alpha) so it
moves smoothly between ticks.
Static props
prop_static is drawn and collided with and never moves. The host groups
them by model each frame and uploads one ModelInstance per copy (transform
and probe) into a growable instance buffer; draw_studio_instances then
draws every copy of a model in one call per mesh, through the
vs_model_instanced entry point, which reads the transform from the
instance buffer instead of the per-draw uniform. Shadows use the matching
vs_shadow_instanced. A static prop’s probe is chosen once and cached.
Physics gives each one a static Box3D box from its model bounds, and that
box joins the ones the player’s hull sweeps against, so it blocks the player
and thrown props without being part of the compiled world.
Dynamic lights and shadows
Everything baked stays baked; a light_dynamic (and the flashlight) is drawn
live on top of it, which is Source 2’s split. crates/kerosene-render/src/lights.rs
does the CPU half and is the reference for the shaders’ light loop:
- Same numbers as a bake. Falloff and cone are
kerosene_math::light, which Radiance now uses too, and intensity is on the lightmap’s scale (color * brightness / 255), so a live light and a baked one with the same_lightread the same. A test pins that at 100 units. - Clustered.
LightFrame::buildkeeps the nearestMAX_LIGHTS(32) whose spheres touch the frustum, and bins each into a 16 x 9 x 24 grid of tiles and exponential depth slices – one bit per light per cluster, 13.5 KiB of uniform. Binning is on the CPU: deterministic, and it works on GL, which has no compute. A fragment looks up its cluster and walks only its bits. - Shadows. A shadow-casting light gets layers of a 512²
Depth32Floatarray, one for a spot and six for a point light, nearest first until the 16 run out; a light that misses out still lights, unshadowed. The host renders each layer before the scene withdraw_world_shadow/draw_studio_shadow, culled by the light’s own PVS and frustum –visible_surfacesfrom the light’s position. Sampling is four hardware-PCF taps with a normal offset that grows with distance, plus slope-scaled depth bias in the pipeline.
The flashlight (flashlight, bound to F; cl_flashlight) is a spot held a
few inches below and right of the eye, so its shadows are visible, with
linear rather than physical falloff so it carries across a room. r_dynamic
and r_shadows switch the two halves off; r_speeds reports lights and
shadow views.
There are no dynamic shadows from the sun: light_environment is baked
whole, and casting a prop’s shadow into it would need the bake to keep the
sun’s share of each luxel separate.
Cubemap probes
Radiance bakes one per env_cubemap into the cubemaps lump
(kerosene_bsp::cubemaps): six faces of RGB9E5, one ray a texel, each the
lightmap where it landed times the surface’s reflectivity. At load,
ProbeChain (probes.rs) builds a box-filtered mip chain down to one texel a
face and GpuProbes uploads it as a 2D texture array, six layers a probe.
Not a cube array: the GL backend lacks those, and the shader’s probe_uv
picks the face itself from a table that must stay a copy of
cubemaps::face_basis (a test holds the Rust side to its inverse).
Roughness chooses the mip level.
Which probe a surface reflects is decided once, in WorldMesh::build_for:
probe_for takes the nearest probe the face’s centre can see, falling back
to the nearest at all, and writes it into every vertex (WorldVertex::probe,
flat-interpolated), so batching by material is untouched. Props choose the
same way from their origin each frame and pass it in ModelUniform::probe.
Probes are map-wide rather than per section, since a probe is reflected by
whatever can see it.
The wgpu layer
crates/kerosene-render/src/gpu.rs is deliberately thin: every what decision
is already made by visible_surfaces, so this is buffer management, pipeline
setup and a draw loop. Materials each get their own bind group; surfaces arrive
sorted by material, so the loop rebinds only when the material changes.
CameraUniform carries view_proj, position, and a params vector of
[unused, time, lightmaps_enabled, fullbright] plus bump/specular scales and
the sky colour; the first slot was exposure, which moved to ToneMapUniform.
The frame bind group is the camera, a section’s lightmap atlas and the map’s
probes. ModelUniform is a full transform, not a displacement — the comment
notes it was three floats until rotated brush entities showed up — plus the
probe a studio model reflects. Props
are drawn through draw_studio_model; debug overlays (prop boxes, streaming
bounds, acoustic rooms) are LineVertex streams uploaded per frame.
crates/kerosene-render/src/camera.rs does the coordinate conversion once:
Kerosene is Z-up +X-forward, graphics APIs want +X-right +Y-up −Z-forward. FOV
is specified horizontally (REFERENCE_ASPECT = 4/3) because that is what a
player judges, and is converted to the vertical angle the projection matrix
wants, so a widescreen monitor shows more at the sides rather than stretching.
Streaming
crates/kerosene-engine/src/streaming.rs decides which sections are resident.
It only decides; the host builds and drops GPU data and reports back with
Streaming::mark_loaded, and physics syncs hulls on a state change.
---
config:
layout: elk
---
stateDiagram-v2
direction LR
[*] --> Unloaded
Unloaded --> Wanted: section has a face in the PAS of the player's cluster
Unloaded --> Wanted: awake physics prop inside it
Wanted --> Loaded: host calls mark_loaded()
Loaded --> Loaded: linger timer while still wanted
Loaded --> Unloaded: unwanted for sv_stream_linger seconds
Wanted --> Unloaded: unwanted before the build finishes
The rule is potential audibility: a section is wanted when any of its faces
sits in a cluster the player’s cluster can hear — the PAS, the PVS flooded
one doorway further. A room therefore starts loading a doorway before it can
be seen, and that doorway is the margin that hides the build. A section that
stops being wanted lingers sv_stream_linger seconds so pacing over a
threshold does not thrash. With no vis data, with the player outside the world,
or with sv_stream 0, everything is wanted.
What streams is only the expensive part: the render mesh, the lightmap atlas
and the rigid-body hulls. The BSP tree, the entity list, the player’s traces
and entity I/O are whole whatever is loaded — the tree is small, traces must
work everywhere, and a logic_relay in an unloaded room still fires. Brush
entities are never streamed: a door is drawn and collided with wherever it has
moved to.
The host side is App::stream_sections: it queues a job on the engine’s
streaming workers (Engine::spawn_job, a bounded pool owned by the
lifecycle manager) per wanted, not-yet-resident section to build WorldMesh + LightmapAtlas from the shared
Arc<Bsp>, and uploads on the main thread when the mpsc receiver delivers.
mark_loaded ignores a build that finishes after the section was dropped
again, so a stale build cannot resurrect it. r_stream_debug 1 draws each
section’s bounds in the colour of its state
(section_debug_lines in host.rs).
This is sections of one map, not an open world: everything is still one
.kbsp, compiled and lit as one.
Next: Physics.