Loading…
gathering yarn balls…
Cat Game Research
Frontier
Dev-log transmissions from the expedition

Rooms That Hear Themselves: CSG Acoustics, Portal Doors, and the FlatBuffers Payoff

The csg test level grew a fifth room, a second door, and a clock that finally ticks in the right decade. This week the BSP portal graph became the sound-propagation graph — room brushes are acoustic volumes, portals are paths, doors are valves — and Steam Audio's ray solve moved onto the job system. Plus: why FlatBuffers is vendored into the build and which of our hand-rolled binary formats should follow it.

There is a clock in the furthest room of our test level. It ticks once a second — or it is supposed to. For the last few sessions it has been quietly explaining, through everything it got wrong, how our acoustic simulation actually works. Walk into the long hall where it hangs and the tick was barely there. Stand anywhere else and it did not update as you moved. Loop it and it stuttered like a clock with a bad escapement. Each of those failures traced to a different layer — a missing room volume, a raycast that believed walls were the shortest path between two points, a reverb estimator reporting its floor value as a measurement, and an audio file that carried over a second of silence it never asked for.

Fixing the clock meant finishing the system around it. This is a dev-log on the CSG acoustic layer as it now stands: the same CSG subdivision that feeds the renderer's cell-portal graph now feeds a room graph for sound propagation, room brushes became acoustic volumes with presets, doors became valves on that graph, and the Steam Audio ray solve moved onto the job system behind an input fingerprint. Then, because the question "why is FlatBuffers in our build" kept surfacing, a section on what that dependency is actually for — and which of our own binary formats should probably follow it.

5 / 4
rooms / room portals
2
door-gated portals
512
rays per source
4.010 s
seamless clock loop
0.5–1.9 s
hall RT60 (sim)

Room brushes are older than they look

Everything here descends from DromEd, the editor shipped with Thief: The Dark Project in 1998 — the same lineage as our CSG brushes, portalize BSP build, and object hierarchy. DromEd designers painted room brushes: invisible volumes laid over the carved air cells of a level, primarily so the engine could propagate sound and feed EAX reverb. The room database was not decoration metadata. It was the level's acoustic skeleton, authored by hand because the geometry alone could not answer "how does sound get from the guard's boots to the player's ear." Our implementation follows the same contract: a brush whose operation = 'room' contributes no render geometry at all — it defines an acoustic volume that must overlap another room volume for sound to cross between them.

That last clause is the physics. Sound in this model does not raycast through a doorway; it travels the room graph, where an edge exists only when two room volumes volumetrically overlap — the overlap slab is the passage. Each room-portal edge carries a transmission coefficient (0–1, the fraction of energy that crosses). A listener-to-source path becomes a route through that graph, and the gain along it is the product of the portal transmissions on the best route found by a max-product Dijkstra over the portal adjacency.

CSG LEVEL — ROOM GRAPH (SOUND PROPAGATION VIEW)Room Adefaultlistener spawnTunnelsmall_deadRoom Blarge_liveCavecaveHalllarge_live · clock emitterportal ×1.0door 1door 2portal ×1.0path A→hall crosses both doors · one sealed → ×0.15 · both → ×0.0225
The five-room chain in the csg level. Overlapping room volumes form portals; doors clamp a portal's transmission to 0.15 when closed.

A path that crosses two closed doors multiplies to 0.15² ≈ 0.02 — the clock becomes a rumor you have to want to hear. Open one and it is still sealed at the other. That is the point of having two doors in the test level: gating has to compose across a chain, not just toggle a single wall.

What shipped this cycle

The topology fix. The long hall existed as geometry but had no room volume — four "uncovered air cell" warnings at load were the tell. A listener standing in it resolved to no room, so the room-graph model disengaged and audio fell back to a lone raycast through several walls. Two brushes fixed the shape of the problem: a small_dead tunnel room around the doorway air, and a 51-meter large_live hall room overlapping the cave. The graph went from 3 rooms / 2 portals to 5 rooms / 4 portals — A ↔ tunnel ↔ B ↔ cave ↔ hall.

Portal chains own cross-room propagation. Both straight-path occlusion mechanisms — the physics sphere cast and Steam Audio's direct-path ray solve — were multiplying the portal gain toward zero. Neither is wrong about what it measures; both measure a straight segment that is not the propagation path when sound goes through a doorway. Cross-room voices now skip both terms entirely: the portal chain's product is the answer, with a floor at the configured minimum occlusion so deep attenuation never fully silences a source.

The simulation moved off the game thread. Direct + indirect Steam Audio solves — 512 rays per source, 4 bounces, a 2-second impulse budget — run on a job-system worker behind an input fingerprint. The main thread writes inputs, commits, dispatches, and harvests the previous round's outputs on a later tick. Rounds only re-fire when something the sim reads actually changed: scene geometry, listener position or room, the reflections flag, or a simulated source's position. Measured rounds run 0.1–12 ms on this level — well inside the ~33 ms cadence budget. The steady-state fingerprint does mean a genuinely drifting listener still dispatches every tick; the skip is for static scenes, not for rescuing noisy input.

The reverb floor bug. Steam Audio's indirect estimator reports RT60 decay times with a floor near 0.1 s. Cross-room, the estimator's impulse response at the listener was legitimately empty — our acoustic scene meshes each room as a closed shell, so rays physically cannot leave the source's room — yet the sim still reported the data as valid, and the 0.1 s floor value was overriding a perfectly good Sabine estimate (the analytic reverberation-time formula, computed per room from volume and surface absorption). Simulated reverb times now only win when the listener shares the voice's room — the only configuration where those rays can actually arrive. Same-room harvests in the hall report 0.5–1.9 s, noise-varying with the Monte Carlo estimator, versus the room's analytic estimate — the space now audibly has a tail.

The clock itself. The "broken clock" turned out to be an asset defect, not a playback bug. The source file was AAC in an MP4 container, and our decoder writes the codec's priming and padding straight into the imported PCM — about 0.18 s of silent preroll and 0.94 s of dead tail in the runtime asset. Every loop played a hole. The replacement is a PCM WAV cut to a 4.010-second loop at quiet points, phase-aligned so the wrap gap is exactly the clip's 1.000-second tick cadence with short cosine fades at the seam. It ticks the way a clock ticks. implemented

Saving is plumbing now. Design-mode saves write the level TOML to the authored source tree when it exists (dev checkout) and always to the exe-relative runtime tree — which is how a packed level gets its first loose override: the vpak entry bytes seed the file, then the save patches it. Portalized cell caches go downstream only; they are regenerable and explicitly excluded from the level pack.

Why it matters for level authoring

The short version: the CSG level is now a place designers can author sound in. Room brushes are paintable acoustic volumes with per-room presets — default, small_dead, large_live, cave, outdoor — that drive both the air-absorption curve over distance and the reverb decay estimate. A designer who wants a dead-sounding corridor paints the corridor. Doors and emitters are ordinary entities in the level TOML with component subtables; the F10 design mode edits them, the portalize step rebuilds cells and the room graph, and saving persists transforms back to the authored TOML.

The room graph that decides what the listener can hear is a sibling of the cell-portal graph that decides what the renderer can skip — both fall out of the same CSG space partition. Visibility and audibility were always the same question.
— the property DromEd exploited in 1998

That is the immersive-sim lineage paying rent. Thief's sound propagation through room brushes was the mechanic — AI heard your footsteps because the same authored room volumes told the engine how far sound carried. We are not there on the AI side yet, but the graph, the doors, the presets, and the save path are all real and testable in csg today.

FlatBuffers: the dependency hiding in the acoustic pipeline

The Steam Audio vendoring question comes up often enough to answer properly. Our build does not pull FlatBuffers because anyone felt like adding a serialization library — it is there because Steam Audio's own object-serialization layer is FlatBuffers all the way down. The engine CMake runs flatc over thirteen upstream .fbs schemas at build time — vector, sphere, triangle, mesh, material, static_mesh, scene, energy_field, reverb, baked_reflection_data, path_visibility, path_data, probe_batch — into generated headers that are pure build artifacts, never committed, and version-locked to the runtime by the commit upstream's dependencies.json names.

Read that schema list as a feature map: it is everything needed to persist a baked acoustic simulation. Scene, mesh, and material describe the geometry the rays bounce off; energy_field, reverb, and probe_batch describe the results of an offline solve; baked_reflection_data and path_data describe how to play those results back without re-solving. Today our simulation is live — rays fly every tick the fingerprint says something moved. The FlatBuffers path is what turns that into a bake-once-at-author-time, load-instantly-at-runtime pipeline: run the indirect solve over probe points when the level is portalized, serialize the energy fields, and the game just reads them. That is on the roadmap precisely because the runtime is already vendored and wired. roadmap

What FlatBuffers actually buys, for anyone who has only met it as a build log line: schema files compile to type-safe accessors; readers bounds-check and tolerate missing fields, so old data loads under new schemas without a version-branch per field; and the wire format is zero-copy — a buffer maps to structs in place rather than deserialize-allocating a parallel object tree. Those properties matter most where a format is a persisted object graph rather than a flat blob stream.

Which is the honest way to survey our own formats. The hand-rolled candidates:

FormatTodayFlatBuffers fit
.adb asset databaseHand-rolled little-endian appends + magic/version checksHigh — a table of records with optional fields is the canonical FB shape; schema evolution kills the version-gate boilerplate
VPak directory indexHand-rolled header + entry table (blob payloads stay raw)Medium — the index is tabular, but the win is modest; payloads are already opaque bytes
Portalized cache (.csg.toml)Text TOML sidecar, checksum-gated, regenerableMedium — binary load is faster than a TOML parse and it is a cache by contract, so forward-compat is a bonus
Save systemUnder design (textbook ch. 11 names FlatBuffers as a candidate)High — savefiles are the classic evolving-schema problem; old saves must load forever
Swarm entity deltas48-byte packed POD structs on the wireLow for deltas — a 48-byte hot-path datagram wants no builder or vtable overhead; higher for session/snapshot transfer where versioning matters

The discipline is the same either way: TOML stays for human-authored truth (levels, config), FlatBuffers earns its place where machines produce and machines read — the asset database, savefiles, baked acoustic fields — anywhere "add a field next month without breaking old files" is a real requirement rather than a hypothetical.

The ledger

QuantityValueSource
Room / room-portal count5 / 4CSGRoom build log
Portalized cells / cell portals27 / 56portalize + CSGPortal logs
Sim round wall-clock0.1–12 ms per ~33 ms ticksim_debug timing
Rays / bounces / IR budget512 / 4 / 2.0 saudio.toml + sim constants
Door transmission closed / open0.15 / 1.0level TOML
Clock loop length / cadence4.010 s / 1.000 sdecoded PCM
Hall RT60 (simulated, same-room)0.5–1.9 s, varies per roundharvest logs
Unit suite658 cases greentest run
A confession on the perf panel
The "13–15 jobs a frame" that looked like runaway simulation backlog was worker threads on a 16-core box — a mislabeled counter, not load. It now reads threads:. Zero stall events; the sim budget was never in danger.

Caveats and limitations

Where this points next

The remaining audio items telescope: portal apertures unlock real cross-room indirect simulation; gapless decoding fixes every looping .m4a; baked acoustics via the already-vendored FlatBuffers schemas turns the live sim into an authoring-time bake. On the design side, the csg vertical slice resumes its main line — F10 brush transform gizmos → portalize → save round-trip — now standing on correct room topology.

The clock ticks. Every second. Through whichever doors you left open.

Related transmissions

← All transmissions