Hey everyone,
I’ve been working on a gas simulation system for my game and wanted to share the current version.
The goal was to create gases that behave more naturally than a simple expanding sphere or particle emitter while still being practical for a large Roblox environment with StreamingEnabled.
The system supports:
- Gas diffusion between voxel cells
- Rising, sinking, and neutral gases
- Pressure equalization
- Obstacles, walls, doors, and openings
- Natural dissipation
- Multiple gas types in the same system
- Player-specific gas behavior modules
- Chunk-based simulation
- Sleeping settled voxels
- Delta replication
- Client-side voxel merging
- Pooled rendering parts
- Cached obstruction raycasts
Current result
The simulation stores gas in a sparse three-dimensional voxel grid.
Each voxel can hold different amounts of one or more gas types. The server is responsible for diffusion, buoyancy, dissipation, obstruction checks, and gameplay effects. Clients only receive nearby visual state.
This separation is especially important with StreamingEnabled, since clients may not have distant walls or geometry loaded. Keeping the simulation server-authoritative prevents gas from incorrectly passing through geometry that has not streamed to a client yet.
Gas movement
Gas spreads according to the concentration difference between neighboring voxel cells.
A voxel with a higher concentration attempts to equalize with nearby lower-concentration voxels. Movement is supported through all six cardinal directions.
Buoyancy is applied as a directional influence rather than replacing normal diffusion.
This means:
- Light gas tends to rise
- Heavy gas tends to sink
- Neutral gas spreads without a vertical preference
Because normal diffusion remains active in every direction, gases form clouds instead of narrow vertical pillars.
Pressure and overflow
Each voxel has a maximum capacity.
If more gas is added than a voxel can contain, the excess is immediately pushed into open neighboring voxels.
This avoids having one extremely overfilled cell slowly release its contents over many simulation updates. The result is a more immediate expansion when a large amount of gas is released at once.
Obstacles and openings
The simulation checks whether gas can move across each voxel boundary.
This allows gas to react to:
- Walls
- Floors
- Ceilings
- Doors
- Windows
- Vents
- Small openings
Obstruction checks are cached.
Once the system determines whether a shared boundary is open or blocked, the result is reused by both neighboring voxels. For static geometry, that boundary generally never needs to be checked again.
Moving doors, destructible walls, and other dynamic geometry can invalidate the relevant cached boundaries when they change.
Chunk-based simulation
Voxels are grouped into spatial chunks.
Chunks are used for:
- Simulation-distance checks
- Nearby replication
- Client-side rendering updates
- Avoiding full-world voxel scans
- Sleeping distant gas regions
This makes the system more suitable for larger environments because it does not need to search every gas voxel for every player or every replication update.
Active scheduling
Processing every voxel at the same frequency became too expensive once clouds reached several thousand cells.
The current system uses a unique round-robin scheduler. Each voxel can only appear once in the scheduler, preventing duplicate queue entries and update starvation.
Voxels can operate in three general states:
Hot
Actively moving gas. These voxels update frequently.
Warm
Mostly settled gas. These voxels update less frequently.
Sleeping
Fully settled gas. These voxels perform no movement calculations until something nearby changes.
Settled gas that still dissipates continues waking periodically so it can fade and eventually be removed. Settled non-dissipating gas can sleep indefinitely.
Sleeping voxels wake when:
- Gas is added
- Gas is removed
- A neighboring voxel changes
- Nearby geometry changes
- A local obstruction cache is invalidated
Simulation budget
The service limits how many voxels can be processed during one simulation step.
This prevents large gas clouds from creating a single large server-frame spike. Rather than updating every voxel at once, the scheduler spreads the workload over time while prioritizing active gas.
Delta replication
The server does not send the complete gas state on every replication update.
Instead, it sends only:
- Newly visible voxels
- Voxels whose contents changed
- Voxels that were removed
- Voxels that left the player’s render range
Updated entries replace the client’s stored state for those voxel keys. Removed entries delete those voxel keys. Anything omitted from a packet remains unchanged.
This reduced network usage significantly compared with sending complete snapshots.
Rendering
The first version rendered one part per voxel.
That became expensive very quickly.
The current renderer groups adjacent voxels when they have:
- The same dominant gas
- Similar density
- The same render chunk
Those voxels are greedily merged into larger blocks.
This can turn many small rendered parts into a much smaller number of larger parts.
Rendered parts are pooled rather than constantly created and destroyed. When gas dissipates or leaves the player’s render distance, the related blocks fade out and return to the pool.
Player gas behaviors
Each gas type can have its own gameplay behavior module.
A behavior can react when a player:
- Enters the gas
- Remains inside the gas
- Leaves the gas
The behavior receives information such as:
- Player
- Character
- Humanoid
- Root part
- Gas type
- Gas amount
- Gas density
- Elapsed time
This keeps gameplay effects separate from the simulation itself.
Examples include:
- Poison gas dealing density-scaled damage
- Smoke reducing visibility
- Fog obscuring distant objects
- Sedative gas applying status effects
- Flammable gas reacting to ignition sources
Dissipation
Each gas type has its own dissipation rate.
Settled gas does not stop dissipating.
Even when a voxel no longer needs frequent movement calculations, it continues waking at a lower frequency so its amount can decrease.
Once the remaining amount stays below the cleanup threshold for long enough, the voxel is removed and replicated to nearby clients.
Gas types with no dissipation can remain permanently after settling.
Debugging and monitoring
The service tracks information such as:
- Processed voxels
- Inspected voxels
- Scheduled voxels
- Total voxel count
- Sleeping voxel count
- Total gas amount
- Chunk count
- Cached face count
Tracking the total gas amount has been especially useful for distinguishing between actual simulation issues and visual rendering issues.
Performance improvements made so far
The current version includes:
- Sparse voxel storage
- Unique round-robin scheduling
- Hot, warm, and sleeping update states
- Maximum work per simulation step
- Chunk-based spatial indexing
- Distant simulation sleeping
- Delta replication
- Cached neighbor references
- Cached obstruction boundaries
- Reduced first-time face sampling
- Minimum transfer required to create a new voxel
- Immediate pressure overflow
- Client-side greedy merging
- Part pooling
- Dirty-chunk-only rendering updates
Current limitations
The system is still voxel-based, so the outer shape can appear blocky.
Greedy merging reduces the number of rendered parts, but it does not create a perfectly smooth cloud surface.
I experimented with runtime EditableMesh rendering, but rebuilding large transparent meshes continuously was less predictable and more expensive than merged pooled parts.
Other current limitations include:
- No wind fields
- No fans or ventilation yet
- Dynamic geometry requires cache invalidation
- Smaller voxel sizes increase simulation cost significantly
- Transparent geometry remains expensive when many layers overlap
I’d be interested to hear how other developers would approach the rendering side, especially for large transparent gas clouds without producing hundreds of over