Ashlight: building a horror engine layer on top of Unity
The systems work behind Ashlight: a custom render feature stack, dynamic dread budget, and the editor tooling that made it possible to ship without burning the team out.
Ashlight is the biggest of the projects under Dreadloom, and most of what I want to write about it is the engine work rather than the design. Horror and immersive sim games live or die on the systems that govern atmosphere: lighting, audio, AI pacing, frame-rate stability under heavy post-processing. Get those wrong and no amount of art direction saves the experience.
Here I will dive into the custom URP render feature stack that handles atmospheric fog, light shafts, and the magic three-zone lighting that is completely new from all shader work I did before.
The Spacial Revelation Shader System
Ashlight's horror visuals are driven by per-sprite materials with a shaderd shader, not a global screen-space pass. Each rendered entity owns it own material instance that responds to Ashlight's runtime state (biome corruption, Lucidity distortion, cognitive trauma). A global pass exists only for screen-wide finishing effects (vignette, chromatic aberration and film grain).
Problems statements
Ashlight's horror identity is not a single visual effect. It's a state-reactive perception system where the same object can read as:
- Safe, fully readable, color-fidelitous: inside the (α) primary light zone;
- Partially readable, edges-only: (β) secondary zone;
- Silhouette-only, dread-coded: (γ) silhouette zone;
- Corrupted, hostile, biomass-organic: Cosmic Horror biome under low light;
- Petrified, geometric, unstable: Cosmic horror buime under flame;
- Distorted, hallucinated, misidentified: low lucidity/high stress states.
These are not pos-processing tints; they are per-entity perceptual states that depend on:
- The entity's position relative to the light source;
- The entity's biome tag;
- The entity's classification (human vs creature vs cosmic anomaly vs architecture);
- The player's current cognitive (sanity, lucidity, trauma stacks).
A single global shader has no way to express "this enemy should look organic under darkness while that enemy stays unchanged, while the architecture petrifies, while the background fog distorts based on the player's sanity". A per-sprite material does.
The two paths I considered
Option α: global pass shader (rejected)
A custom URP ScriptableRendererFeature that runs after the opaque queue, samples the scene texture and applies horror distortion based on uniform globals (player position, sanity, lucidity).
Pros:
- Single shader file, one replace to tune the look.
- Cheap GPU cost (one full-screen pass).
- No material proliferation, no per-sprite setup.
Cons (the dealbreakers):
- No per-entity discrimination. A global pass cannot tell "A can petrify, B should not" whitout either (a) a complex stencil/ID buffer setup or (b) authoring entity specific masks per frame. Both rebuilt the per-material problem at a higher engineering cost!
- No diegetic interaction with the light sources. The α, β and γ are spatial readability bands centered in the light source. A global pass treats all pixels equally. We could have to fake locality through scren-space distante to a uniform light point, which fails for any sprite the player is not directly facing and fails completely for occluded targets behind walls/foreground.
- Biome reactivity becomes a soup. Two biomes share screen space at boundaries: a global pass cannot blend their distinct horror languages cleanly.
- Cannot author per-sprite art intent. Tech art for some actions glow, an enemy's legendary action glyph, a corrupted architectural detail; each of these needs material-level authoring control, and a global pass strips that.
- Frame-coherent only. Global passes resolve once per frame, per-entity transitions (entering stagger, becoming petrified, glyph windup) want material-level parameters that animate independently per object.
Option β: per-sprite material (chosen)
Every rendered entity (player, enemy, prop, architecture tile, FX) uses a material instance based on a small set of master shaders. The material holds the entity's perceptual contract: its biome tag, its horror category, its current state. A C# component pushes runtime state into the material's properties each frame.
Pros
- Per-entity reactivity. Each sprite knows its own perceptual identity.
- Light source interaction is diegetic and spatial: the shader samples a per-frame Censer position/radius and computes its own readability zone.
- Biome behavior is a material flag, not a screen region; works at biome boundaries.
- Tech art authoring lives where the art lives (the material), not in a global tuning panel.
- Compositing-friendly: layered effects (corruption + cognitive distortion + glyph overlay) stack at the material level.
Cons (managed, not eliminated)
- More draw calls (one material instance per entity in the worst case).
- More memory (material instances).
- Tuning happens across many materials, mitigated by shared
ScriptableObject"look profiles" the materials reference. - Risk of shader proliferation, mitigated by keeping the master shader count small (3–5 archetypes; see §4). The cost is real but bounded. The expressivity is the entire game's identity. Trade made.
Architecture overview
Diagram source:
ShaderArchitecture.svg. Top-down data flow from globals → master shaders → authored materials → per-entity runtime state
Three layers of state
| Layer | Lifetime | Scope | Mechanism |
|---|---|---|---|
| Globals | Per frame | Whole scene | Shader.SetGLobalFloat/Vector from a single ShaderGlobalsBroadcaster MonoBehaviour |
| Material | Authored once, edited in editor | Per shared art family | Material asset properties |
| Per-instance | Per object per frame | Per entity | MaterialPropertyBlock (no material instance duplication) |
The crucial point: per-instance overrides via MaterialPropertyBlock give us per-entity reactivity without spawning a new material instance per entity. GPU instancing keeps draw calls bounded.
The light source (Censer) is a global uniform, not a light
The light sources (Censer) do no use Unity's 2D lighting system. It is a set of shader globals (_CenserPosition, _CenserRadius_* and _CenserStrength). Every material samples those globals and computes its own readability zone:
// pseudo
float dist = distance(worldPos, _CenserPosition);
float primary = step(dist, _CenserRadius_Primary);
float secondary = step(dist, _CenserRadius_Secondary) - primary;
float silhouette = 1.0 - step(dist, _CenserRadius_Silhouette);This is intentional: it lets the shader respond to the Censer even through walls, even off-screen. A 2D Light system would clip to line-of-sight, which conflicts with the GDD's intent that Silhouette-zone information remains macro-readable.
Biome reactivity is per-material, gated by global biome ID
_BiomeAffinity on the amaterial declares "I belong to biome x"; _BiomeId is the global broadcast the dominant diome the player is in. The shader branches:
bool inOwnBiome = (_BiomeAffinity == _BiomeId);
// Body Horror entity in Body Horror biome under low light: organic corruption rises
// Cosmic entity in Cosmic biome under light: petrification animates inEntities outside their affinity biome render naturally; they do not pretend to belong.
Cognitive Decay distortion is per-entity sensitive
A global pass would distort the entire screen by _PlayerLucidity01. We instead let each material declare _DistortionAmount: how susceptible this entity is to misperception. The player's own sprite has 0 (the player is always themselves). A cosmic anomaly has 1.0 (perception is unreliable around it). A wall has 0.1 (subtle drift at high stress).
This is the difference between the world distorting and the player's perception of the world distorting. Ashlight needs the latter.
Global post-process pass (the narrow place a global pass does belong)
Per-sprite materials handle entity-level reactivity. A global URP ScriptableRendererFeature handles screen-finishing effects that have no per-entity meaning:
- Vignette (intensifies with Stress);
- Chromatic aberration (proportional to Lucidity loss);
- Film grain (constant atmospheric);
- Optional CRT-style scanlines for Trauma-driven "memory degradation" passages.
These are correctly screen-uniform. They never need to know which sprite is the player or the wolf. Authoring them as a global pass is appropriate, and that's the only place a global pass is appropriate.
Performance budget and mitigation
| Concern | Mitigation |
|---|---|
| Material instance count | Use MaterialPropertyBlock for per-instance variation; share one material per art family + state combo. |
| Draw call count | GPU instancing on master shaders; sprite atlasing aligned with material families. |
| Uniform updates per frame | One ShaderGlobalsBroadcaster writes all globals; per-entity components only update their own MaterialPropertyBlock when state changes. |
| Shader variant explosion | multi_compile keywords kept minimal (_BIOME_BODY, _BIOME_COSMIC, _COGNITIVE_DECAY_ON); no per-entity keywords. |
| Tuning friction across many materials | Shared ScriptableObject "Look Profiles" referenced by materials, edited in one place. |
Target: 2D scenes with 50–150 sprites should stay well under the URP frame budget on mid-range hardware. This will be validated once the master shaders ship.
Trade-offs we accepted
- More upfront tech-art work. Authoring 5 master shaders + the globals broadcaster + the per-entity component is more code than a single global pass would be. We accepted this in exchange for the system being able to express the GDD's identity.
- Material proliferation risk. Solved at the discipline level: any new shader proposal must justify why an existing master can't handle it. Default answer: "extend the existing master."
- Globals coupling. Every material reads the same globals. If the globals' contract changes, every shader updates. Mitigated by treating the globals list as a versioned interface (see §8).
Open questions
- Should
MaterialPropertyBlockbe set every frame, or only on state change? (Lean: only on change, dirty-flag style.) - Where does the Censer's biome reactivity rule live: in the shader (branch on
_BiomeId), or in the C# layer that picks which master shader the material uses? (Lean: shader branch, so a single entity prefab works in any biome.) - How does the editor preview Censer-zone visuals without a running player? Likely a sceneview-only
CenserPreview.csthat writes the globals inOnDrawGizmos. - Do we need a fallback non-URP shader for sprite editor previews / thumbnail rendering?
That was it for the shader deep-dive!