mittens-engine 0.7.0

A Vulkan and OpenXR scene engine with ECS, reactive signals, and Meow Meow scripting
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
# Render graph: post-processing

This document designs the opt-in post-processing system for cat-engine, covering bloom and depth-of-field (bokeh) as the first two effects. It also covers the emissive pipeline refactor needed to support them cleanly.

**Scope:**
- Emissive as a float intensity + dedicated pipeline
- BloomComponent and BokehComponent design
- RenderGraphComponent as the scene-facing opt-in marker
- Render graph changes (intermediate images, passes, ordering)
- XR considerations

**Not in scope:**
- SSAO, SSR, TAA, tone mapping (noted as future work)
- Full render-to-texture capture systems (mirrors, portals, monitors)

**Related:**
- `docs/spec/render-phases.md` — existing phase ordering
- `docs/spec/render-to-texture.md` — implemented runtime-texture publication bridge
- `docs/spec/renderer-stats-component.md` — renderer diagnostics
- `docs/spec/render-graph-pipeline.svg` — diagram: base pipeline
- `docs/spec/render-graph-pipeline-post-processing.svg` — diagram: post-processing render graph
- `src/engine/graphics/vulkano_renderer.rs` — current render loop
- `src/engine/graphics/vulkano_swapchain.rs` — swapchain/depth image setup
- `assets/shaders/toon-mesh.frag` — current emissive branch

---

## Runtime status snapshot

This document still contains some forward-looking design material, but the current runtime shape is:

- Bloom is implemented.
- The post-process target set currently uses the runtime attachment names from
    `PostProcessFrameTargets`:
    - `main_color`
    - `main_msaa_color`
    - `depth`
    - `bloom_source_msaa`
    - `bloom_source`
    - `bloom_a`
    - `bloom_b`
- Overlay is deferred when post-processing is active so scene depth from opaque/cutout can occlude
    emissive extraction.
- Overlay is then drawn back into the main scene color before the final fullscreen composite, so it
    can still receive bloom.
- `BokehConfig` exists in the data model, but the bokeh pass chain described later in this document
    is not currently wired into the runtime render loop.

## Bloom / overlay behavior

The current target semantics are:

- **Opaque** geometry occludes the emissive extraction pass.
- **Cutout** geometry occludes the emissive extraction pass.
- Regular transparent geometry is still a separate question and is not part of this change.
- **Overlay** should remain visible in the main color path and therefore be affected by bloom in
    the final composite.
- **Overlay should not contribute to the emissive-source extraction pass** for this change.

This gives the desired artistic result:

- bloom glow is clipped by solid scene geometry (`Opaque` / `Cutout`)
- overlay visuals still receive the final bloom treatment because they are present in main color
- overlay does not become a new bloom source just by existing in the overlay phase

An important consequence is that the depth used by emissive extraction must reflect the
scene-depth result from opaque/cutout rendering, not an overlay-only depth clear/rewrite.

---

## 1. Current pipeline state

Everything renders in a **single `begin_rendering()` → `end_rendering()` dynamic-rendering scope**:
- Color: MSAA optional (4x), resolves to sRGB swapchain image
- Depth: `D32_SFLOAT`, MSAA-matched, **store op = `DontCare`** (discarded after render)
- Emissive: per-instance `u32` flag on `VisualInstance`; in the fragment shader it just early-returns before lighting
- No intermediate images, no readback, no post-processing passes

The depth being discarded is the critical constraint for any depth-dependent post-processing (bloom occlusion, depth-of-field).

---

## 2. Emissive pipeline refactor

### 2.1 Why emissive needs its own pipeline

Currently the toon-mesh fragment shader has a single branch:
```glsl
if (mat.emissive != 0u || v_emissive != 0u) {
    f_color = vec4(base, base_rgba.a);
    return;
}
// ... rest of lighting (SSBO reads, quantization, etc.)
```

This causes **intra-warp divergence** whenever emissive and non-emissive surfaces land in the same draw batch: all 32–64 fragments in the warp must execute the lighting path even if only one fragment is non-emissive.

More importantly for bloom: the emissive prepass needs to draw *only emissive instances*. With a shared shader + flag, that requires scanning all instances and re-sorting. With a dedicated `MaterialHandle`, emissive objects already sort into their own draw batches — the emissive prepass is just "draw batches that use `EMISSIVE_TOON_MESH`."

### 2.2 New emissive material handles

Add two new `MaterialHandle` variants:
- `EMISSIVE_TOON_MESH` (unskinned emissive)
- `SKINNED_EMISSIVE_TOON_MESH` (skinned emissive, same vertex shader as `SKINNED_TOON_MESH`)

Both use a new fragment shader with **no lighting code at all**:
```glsl
// emissive-toon-mesh.frag (conceptual)
layout(location = 0) out vec4 f_color;

void main() {
    vec4 base_rgba = texture(tex, v_uv) * v_color;
    float intensity = v_emissive_intensity;  // HDR float from instance buffer
    f_color = vec4(base_rgba.rgb * intensity, base_rgba.a);
}
```

~10 lines, no SSBO reads, no loop over lights.

The `toon-mesh.frag` shader loses the `v_emissive` branch entirely — objects either use the emissive pipeline or the toon pipeline, never both.

### 2.3 EmissiveComponent: bool → float intensity

Currently:
```rust
pub struct EmissiveComponent {
    pub enabled: bool,
}
```

Proposed:
```rust
pub struct EmissiveComponent {
    pub intensity: f32,  // 0.0 = disabled, 1.0 = normal, >1.0 = HDR overbright for bloom
}
```

- `EmissiveComponent::on()` returns `intensity: 1.0`
- Old `enabled: true` = `intensity: 1.0`, `enabled: false` = `intensity: 0.0` (easy migration)
- When a component has `intensity > 0.0`, `RenderableSystem` assigns it the `EMISSIVE_TOON_MESH` material handle
- `VisualInstance.emissive` changes from `u32` to `f32` (the intensity)
- `InstanceData.i_emissive` changes from `u32` to `f32` — zero cost in the GPU vertex attribute

This change is backwards-compatible in behavior: existing emissive objects continue to appear unlit at intensity 1.0.

### 2.4 Draw sorting consequence

Since `MaterialHandle` is part of draw batch sorting, emissive objects automatically batch separately from toon objects. The batching requires no changes.

---

## 3. Component design

### RenderGraphComponent

A global scene marker that activates the post-processing path. One per scene (not per camera). When absent, the renderer takes the current fast path unchanged — no intermediate images, no extra passes, no overhead.

```rust
pub struct RenderGraphComponent {
    pub enabled: bool,
}
```

Authoring shape: add anywhere in the scene (typically a sibling of the camera rig or a scene root child):
```
SceneRoot
  ├── CameraRig { ... }
    ├── RenderGraphComponent
    │     ├── EmissivePassComponent { ... }  // optional
    │     ├── BloomComponent { ... }
    │     └── BokehComponent { ... }         // optional
  └── ...scene...
```

When `RenderGraphComponent` is present, the renderer:
1. Allocates an intermediate color image (not the swapchain directly)
2. Activates the stored-depth path if any child component requires it
3. Runs the child-specific passes after geometry

### BloomComponent

```rust
pub struct BloomComponent {
    pub intensity: f32,       // Additive blend strength (default 1.0)
    pub radius_ndc: f32,      // Bloom spread in NDC units (default 0.05; see below)
    pub emissive_scale: f32,  // Scales emissive intensity in the bloom prepass (default 1.0)
    pub half_res: bool,       // Blur at half resolution for performance (default true)
    pub source: BloomSource,  // Which geometry feeds the bloom prepass (default Emissive)
}

pub enum BloomSource {
    Emissive,   // Only EMISSIVE_TOON_MESH batches (default, cheapest)
    // Future: All, LuminanceThreshold(f32)
}
```

**`radius_ndc` to pixels conversion.** The renderer converts `radius_ndc` to a pixel
half-width at render setup time using:

```rust
fn ndc_radius_to_pixels(radius_ndc: f32, viewport_width: u32) -> u32 {
    ((radius_ndc * viewport_width as f32) / 2.0).round().max(1.0) as u32
}
```

`radius_ndc = 0.2` on a 1920-wide viewport → `192 px` Gaussian half-width.
`radius_ndc = 0.05` → `48 px` (a reasonable default for subtle bloom).

This makes bloom spread resolution-independent — a scene authored at 1080p looks the same
at 4K. The half-res path (`half_res = true`) halves the effective pixel count before
blurring, then upsamples; `radius_ndc` stays unchanged.

**MMS authoring form:**

```
RenderGraph {
    Bloom.radius(0.2).intensity(0.8) {
        quality = 0.5      // scales kernel sample count (0..1, default 1.0)
        EmissiveSource     // positional tag: bloom source = emissive geometry (default)
    }
    Bokeh { ... }
}
```

The chained `.radius(0.2).intensity(0.8)` desugars in the parser: `radius` becomes the
constructor call, `intensity` becomes a body `Call`. Both map to builder methods on
`BloomComponent`. `EmissiveSource` is a bare positional tag handled by `apply_positional`
for `BloomComponent` — it selects `BloomSource::Emissive` (the default, so it's mostly a
documentation hint today; future variants would be `AllSource`, `LuminanceSource(f32)`).

Requires: emissive objects using `EMISSIVE_TOON_MESH` material.
Does not require depth (occlusion of glow is an enhancement, see open questions below).

### BokehComponent

```rust
pub struct BokehComponent {
    pub focus_distance: f32,   // World-space focal plane (meters)
    pub aperture: f32,         // Controls CoC radius (larger = more blur)
    pub max_blur_radius: f32,  // Max circle-of-confusion radius in pixels (default 8.0)
}
```

Requires: stored depth buffer (triggers depth store + optional MSAA depth resolve).

---

## 4. Bloom pipeline

### 4.1 Emissive prepass

After the scene geometry needed for emissive occlusion is established, render emissive objects into
a dedicated HDR buffer:

- Format: `R16G16B16A16_SFLOAT` (HDR; allows values > 1.0)
- Size: full or half-resolution (`BloomComponent::half_res`)
- Clear: `(0, 0, 0, 0)`
- Depth test: ON if stored depth is available (reuse main depth attachment, no write); otherwise OFF
- Draw: only batches using `EMISSIVE_TOON_MESH` or `SKINNED_EMISSIVE_TOON_MESH`

For the intended behavior above, the reused depth should contain the occluding result of:

- opaque scene geometry
- cutout scene geometry

and should **not** be replaced by an overlay-only depth clear before emissive extraction.

**Depth occlusion for glow**: If the emissive prepass reuses the stored depth (read-only), bright objects behind walls won't bleed glow through them. If depth is not stored (e.g., no `BokehComponent`), glow bleeds through walls — acceptable for an initial implementation, can be addressed later by always storing depth when bloom is active.

**Performance note**: If there are no emissive instances in the scene this frame, skip the pass entirely.

### 4.2 Gaussian blur (two-pass separable)

Two full-screen passes on the emissive buffer:
1. Horizontal Gaussian blur → temp buffer
2. Vertical Gaussian blur → blurred emissive buffer

Kernel: separable Gaussian, width = `2 * blur_radius + 1`.

If `half_res = true`, both passes run at half resolution. The blur hides the resolution difference.

### 4.3 Bloom composite

Full-screen additive blend:
```
final_color = main_color + bloom_blurred * bloom_intensity
```

Because overlay remains part of `main_color` in the intended behavior, overlay visuals naturally
receive the bloom composite even when they do not contribute to the emissive extraction source.

If main color is sRGB (which it currently is), the additive blend needs to happen in linear space. This is an argument for keeping the intermediate image as a linear float format.

Output: main color buffer (updated in-place or written to a new buffer).

---

## 5. Depth-of-field (bokeh) pipeline

### 5.1 Depth requirements

Current depth: MSAA `D32_SFLOAT`, store op = `DontCare`. Changes required when `BokehComponent` is present:
- **Store op → `Store`** (retain depth after render)
- **If MSAA enabled**: allocate a single-sampled `R32_SFLOAT` image and resolve MSAA depth into it (Vulkan supports depth resolve via `VK_KHR_depth_stencil_resolve`)
- **If MSAA disabled**: use depth image directly as shader input

This depth image covers the full frame (all geometry phases). Since depth is cleared mid-frame for the overlay phase, the stored depth will be the opaque+transparent scene depth, not overlay.

### 5.2 CoC computation

Full-screen pass or compute shader:
- Input: stored depth image
- Camera data: near/far planes, focal length, focus distance, aperture
- Output: `R16_SFLOAT` circle-of-confusion radius image (pixels)

Simplified CoC formula (pinhole camera model):
```
depth_linear = (2 * near) / (far + near - depth_ndc * (far - near))
coc = abs(depth_linear - focus_distance) / depth_linear * aperture
coc_pixels = coc * viewport_width / sensor_width
```

Clamp CoC to `[0, max_blur_radius]`.

### 5.3 Bokeh blur

Variable-radius blur using the CoC image:
- Input: main color image, CoC image
- Output: blurred color image

Options (in order of quality/cost):
1. **Separable Gaussian with per-pixel radius** — fast, cheap, slightly incorrect but fine for game use
2. **Hexagonal bokeh kernel** — more photorealistic shape, GPU-heavy
3. **Dual-Kawase blur** — fast approximation, fewer passes

For the initial implementation, separable Gaussian with per-pixel radius is recommended.

### 5.4 DoF composite

Blend sharp vs blurred by CoC:
```
weight = saturate(coc / max_blur_radius)
final_color = lerp(sharp_color, blurred_color, weight)
```

Center pixels (CoC ≈ 0) are fully sharp; peripheral pixels are fully blurred.

---

## 6. Intermediate image allocations

This section is still the design-oriented allocation model. For the exact current runtime
attachment set and pass order, see section 7 below.

All allocations are conditional — if the triggering component isn't present, the memory isn't used.

| Image | Trigger | Format | Size | Notes |
|---|---|---|---|---|
| Main color intermediate | `RenderGraphComponent` | `R16G16B16A16_SFLOAT` | Full res | Geometry renders here instead of swapchain |
| HDR emissive buffer | `BloomComponent` | `R16G16B16A16_SFLOAT` | Full or half res | Emissive prepass target |
| Blurred emissive buffer | `BloomComponent` | Same as above | Same | Ping-pong for H+V blur |
| Stored depth (resolved) | `BloomComponent` or `BokehComponent` | `R32_SFLOAT` | Full res | MSAA depth resolve target |
| CoC image | `BokehComponent` | `R16_SFLOAT` | Full res | Per-pixel CoC radius |
| DoF blurred image | `BokehComponent` | Same as intermediate | Full res | Blurred scene color |

**Without `RenderGraphComponent`**: nothing is allocated, current code path runs unchanged.

---

## 7. Current runtime render graph sequence

The diagram in `docs/spec/render-graph-pipeline-post-processing.svg` should be read as the current
implemented bloom path, not the older conceptual “bloom composite back into the intermediate” flow.

### 7.0 Runtime post-process pipelines

The current post-process path uses a separate `PostProcessingRenderer` with its own fullscreen
graphics pipelines. These pipelines are distinct from the normal scene/material pipelines used for
renderables, draw batches, and instanced geometry.

Current runtime pipeline families:

- **Blit / copy pipeline**`post-process-fullscreen.vert` + `post-process-copy.frag`
- **Bloom blur pipeline**`post-process-fullscreen.vert` + `post-process-bloom-blur.frag`
- **Bloom composite pipeline**`post-process-fullscreen.vert` +
    `post-process-bloom-composite.frag`

Important properties of these pipelines:

- They are owned and cached by `PostProcessingRenderer`, not by `Renderable`, `MaterialHandle`, or
    `VisualWorld` batching.
- They are keyed by output color format rather than mesh/material identity.
- They bind sampled textures through a post-process descriptor-set layout, not the normal scene
    material/texture binding path.
- They render a fullscreen primitive (`draw(3, 1, 0, 0)`) rather than drawing scene meshes.
- They run in their own rendering scopes after the main geometry scope.

### 7.1 Runtime attachment set

Per frame (or per eye in XR), the renderer currently allocates the following post-process targets:

| Attachment | Present when | Notes |
|---|---|---|
| `main_color` | `RenderGraphComponent` active | Single-sample scene color used as the sampled main scene input |
| `main_msaa_color` | MSAA enabled | MSAA render target that resolves into `main_color` |
| `depth` | Post-processing active | Shared scene depth used during geometry and bloom extraction |
| `bloom_source_msaa` | Bloom + MSAA enabled | MSAA emissive extraction target that resolves into `bloom_source` |
| `bloom_source` | Bloom enabled | Full-resolution sampled emissive extraction result |
| `bloom_a` | Bloom enabled | Ping-pong blur target, half-res when `half_res(true)` |
| `bloom_b` | Bloom enabled | Second ping-pong blur target, same size as `bloom_a` |

All color targets currently use the renderer `color_format` selected for the post-process path.

```
No RenderGraphComponent:
  [geometry phases, including overlay] → swapchain

With RenderGraphComponent:

[1] Main geometry scope (begin_rendering → end_rendering)
    Color: opaque/cutout/transparent scene draws into `main_msaa_color` if MSAA is on,
         otherwise directly into `main_color`
    Resolve: `main_msaa_color` → `main_color` when applicable
    Depth: shared `depth`
    Overlay: NOT drawn here when post-processing is active

[2] Emissive extraction scope (skip if no BloomComponent or no emissive batches)
    Color: `bloom_source_msaa` if MSAA is on, otherwise `bloom_source`
    Resolve: `bloom_source_msaa` → `bloom_source` when applicable
    Depth: load existing scene depth from [1]
    Draw: emissive opaque/cutout batches only, using read-only `LessOrEqual` depth testing

[3] Bloom prefilter / copy
    Input: `bloom_source`
    Output: `bloom_a`
    Notes: fullscreen blit into the blur-resolution target; this is where full-res emissive is
         sampled down to half-res when `half_res(true)` is enabled

[4] Bloom blur H
    Input: `bloom_a`
    Output: `bloom_b`

[5] Bloom blur V
    Input: `bloom_b`
    Output: `bloom_a`

[6] Overlay scope
    Color: reload scene color (`main_msaa_color` + resolve to `main_color`, or `main_color`)
    Depth: cleared only for overlay self-occlusion
    Draw: overlay batches
    Notes: this happens after emissive extraction, so overlay does not become a bloom source,
         but before final composite, so overlay still receives bloom

[7] Final fullscreen composite / blit
    Input: `main_color` + optional blurred bloom from `bloom_a`
    Output: final output view (swapchain or XR eye target)

[8] Optional debug panels
    Input: final output + selected post-process textures
    Output: final output view with overlay debug quads
```

Passes [2]–[8] are separate fullscreen or overlay scopes after the main geometry scope. The old
bokeh/DoF chain described later in this document remains design material for now rather than the
current runtime path.

### 7.2 Bloom filtering

Bloom sampling currently uses a single linear sampler in `PostProcessingRenderer`:

- `mag_filter: Linear`
- `min_filter: Linear`

That means:

- emissive extraction copied from `bloom_source` into `bloom_a` is linearly filtered
- the half-resolution bloom path is downsampled through linear sampling
- both blur passes sample linearly
- the final composite samples the blurred bloom texture linearly when reading it back at full
  output resolution

So yes: when the blurred bloom result is sampled back up during final composite, it currently uses
linear filtering.

---

## 8. XR considerations

In XR mode, the engine renders each eye into a separate `XrOffscreenTarget` (color + depth). Post-processing must run per-eye.

Each eye needs its own:
- Main color intermediate
- HDR emissive buffer (if bloom)
- Blurred emissive buffer (if bloom)
- Stored depth (if bloom w/ occlusion or bokeh)
- CoC image (if bokeh)
- DoF blurred image (if bokeh)

The post-processing passes run twice — once for left eye, once for right — after the geometry for each eye is recorded.

**Alternative (future)**: Use `VK_KHR_multiview` for single-pass stereo post-processing. Not worth the complexity until per-eye approach is proven.

---

## 9. sRGB vs linear color space

Currently the swapchain is sRGB (`R8G8B8A8_SRGB`). The proposed main color intermediate uses `R16G16B16A16_SFLOAT` (linear).

This means:
- Geometry output is in linear space (no implicit sRGB conversion mid-frame)
- Bloom blur happens in linear space — correct behavior (blending in sRGB space darkens colors)
- The final swapchain blit applies gamma correction (sRGB encode)

If the intermediate is kept as sRGB, the bloom additive blend produces slightly wrong colors (darker than physically correct). Linear intermediate is the right call.

---

## 10. Performance and overhead summary

| Scenario | Overhead |
|---|---|
| No `RenderGraphComponent` | Zero — current fast path unchanged |
| `RenderGraphComponent` only | One extra blit to swapchain (minor) |
| + `BloomComponent` (half-res) | Emissive prepass + 2 half-res blurs + composite |
| + `BloomComponent` (full-res) | Emissive prepass + 2 full-res blurs + composite |
| + `BokehComponent` | Depth store + CoC pass + blur + composite |
| Both bloom + bokeh | All of the above; depth stored once for both |

**Emissive prepass cost**: nearly free if there are few emissive instances. The prepass only draws `EMISSIVE_TOON_MESH` batches.

**Blur passes**: the most expensive part. Two full-screen passes per blur axis. Half-res bloom is strongly recommended by default.

**Depth store on desktop**: changing `DontCare` → `Store` is effectively free (memory write already happened).

---

## 11. Future effects (same infrastructure)

These effects could be layered in later under the same `RenderGraphComponent` tree:

- **SSAO** (screen-space ambient occlusion): needs stored depth and normals; similar to bokeh CoC pass
- **SSR** (screen-space reflections): needs stored color + depth
- **Tone mapping / color grading**: natural fit for the final swapchain blit pass (step [9])
- **Chromatic aberration**: trivial full-screen pass; no extra buffers
- **TAA** (temporal anti-aliasing): replaces MSAA; requires per-instance velocity/motion vectors (new vertex output)
- **Vignette / lens distortion**: trivial full-screen pass; no extra buffers

---

## 12. Open questions

1. **Emissive prepass depth for bloom occlusion**: should we always store depth when `BloomComponent` is present, to avoid glow bleeding through walls? Or leave it as an opt-in (only if `BokehComponent` also present)?
   - **Proposed**: always store when `BloomComponent` is present. The depth store cost on desktop is negligible.

2. **Emissive prepass placement**: before vs after main geometry passes?
   - **Proposed**: after. The stored depth from the main passes is then available for read-only depth test in the emissive prepass.

3. **BloomComponent scope**: one per scene vs one per camera?
    - **Proposed**: one per scene (child of `RenderGraphComponent`). XR handles per-eye duplication internally without needing per-camera bloom configuration.

4. **Material handle for emissive objects not using `GLTFComponent`**: user-authored objects need to opt into `EMISSIVE_TOON_MESH` explicitly, or the engine assigns it automatically when `EmissiveComponent` is present.
   - **Proposed**: `RenderableSystem` assigns the emissive material handle automatically when it sees `EmissiveComponent` with `intensity > 0.0` in the same subtree. No user plumbing required.