dof
This commit is contained in:
+353
-227
@@ -1,298 +1,424 @@
|
||||
# Étape 24 — MSAA 4× (Anti-aliasing multi-échantillons)
|
||||
# Étape 26 — Depth of Field (DoF)
|
||||
|
||||
**Statut** : ⬜ En cours
|
||||
**Roadmap** : 6.4
|
||||
**Prérequis** : Pipeline HDR (étape 20) + Bloom (étape 23)
|
||||
> **Objectif** : Flou de profondeur post-process — les objets hors de la distance
|
||||
> de focus sont flous, créant un effet cinématique. Opt-in via `with_dof()`,
|
||||
> zéro coût quand désactivé.
|
||||
|
||||
---
|
||||
|
||||
## Problème
|
||||
## Contexte & motivation
|
||||
|
||||
Sans anti-aliasing, les bords des géométries présentent du **staircasing** (aliasing) :
|
||||
les silhouettes ont des escaliers visibles, surtout sur les contours fins et les
|
||||
lointains. C'est le défaut visuel le plus flagrant d'un moteur sans post-process.
|
||||
Le DoF (Depth of Field) simule le comportement d'un objectif photo : seuls les
|
||||
objets à la distance de focus sont nets, le reste est flou. Utilité :
|
||||
|
||||
## Solution
|
||||
- **Effet cinématique** — mettre en scène un objet/personnage
|
||||
- **Guidage du regard** — diriger l'attention du joueur
|
||||
- **Masquage subtil** — flou les zones non pertinentes (alternative douce au fog)
|
||||
|
||||
Rendre la scène avec **N échantillons par pixel** (4× par défaut), puis **résoudre**
|
||||
(en moyenne) vers une texture single-sample. Le reste du pipeline (bloom, TM)
|
||||
opère sur la texture résolue — aucun changement.
|
||||
### Pipeline existant (avec HDR)
|
||||
|
||||
```text
|
||||
Main pass → HDR texture (Rgba16Float)
|
||||
↓
|
||||
Bloom (si actif) → composite
|
||||
↓
|
||||
Tone Mapping → surface
|
||||
```
|
||||
|
||||
### Pipeline avec DoF
|
||||
|
||||
```text
|
||||
Main pass → HDR texture + depth buffer
|
||||
↓
|
||||
Bloom (si actif) → bloom_composite
|
||||
↓
|
||||
DoF (si actif) :
|
||||
CoC pass: depth → coc_texture (R16F, radius en px)
|
||||
Blur pass: color + coc → dof_output (Rgba16F)
|
||||
↓
|
||||
Tone Mapping → surface
|
||||
```
|
||||
|
||||
Quand DoF est désactivé : TM lit directement la texture HDR/bloom (zéro coût).
|
||||
|
||||
---
|
||||
|
||||
## Pipeline actuel vs avec MSAA
|
||||
## Décisions
|
||||
|
||||
### Sans HDR, sans MSAA (actuel)
|
||||
```
|
||||
Scene → swapchain (Rgba8UnormSrgb) → present
|
||||
### D1 — 2 passes : CoC + Blur
|
||||
|
||||
| Pass | Entrées | Sortie | Format |
|
||||
|------|---------|--------|--------|
|
||||
| CoC | depth texture | coc_texture | `R16Float` (1 canal, radius en pixels) |
|
||||
| Blur | color + coc | dof_output | `Rgba16Float` (4 canaux, couleur floutée) |
|
||||
|
||||
Le CoC est calculé séparément pour éviter de recalculer la linearisation du
|
||||
depth dans chaque tap du blur.
|
||||
|
||||
### D2 — Formule du CoC
|
||||
|
||||
```wgsl
|
||||
// Linearize NDC depth [0,1] → world distance (perspective)
|
||||
fn linearize_depth(ndc_z: f32, near: f32, far: f32) -> f32 {
|
||||
return near * far / (far - ndc_z * (far - near));
|
||||
}
|
||||
|
||||
// CoC in pixels:
|
||||
let dist = linearize_depth(depth, near, far);
|
||||
let coc = max_blur * aperture * abs(dist - focus_distance) / max(focus_distance, 1e-4);
|
||||
coc = min(coc, max_blur);
|
||||
```
|
||||
|
||||
### Sans HDR, avec MSAA
|
||||
```
|
||||
Scene → MSAA texture (4×, format surface) ──resolve──→ swapchain → present
|
||||
+ MSAA depth (4×)
|
||||
```
|
||||
Le resolve est fait **automatiquement par wgpu** dans le render pass
|
||||
(`resolve_target` sur la color attachment).
|
||||
- `focus_distance` : distance (unités monde) où l'image est parfaitement nette
|
||||
- `aperture` : 0.0–1.0, contrôle l'intensité du flou (0 = pas de flou)
|
||||
- `max_blur` : radius maximum en pixels (clamp, évite le flou excessif)
|
||||
|
||||
### Avec HDR, sans MSAA (actuel)
|
||||
```
|
||||
Scene → HDR texture (Rgba16Float) → [Bloom] → TM → swapchain → present
|
||||
### D3 — Uniform struct (32 bytes)
|
||||
|
||||
```wgsl
|
||||
struct DoFUniform {
|
||||
focus_distance: f32, // world units
|
||||
aperture: f32, // 0.0-1.0
|
||||
max_blur: f32, // pixels
|
||||
near: f32, // camera near plane
|
||||
far: f32, // camera far plane
|
||||
inv_width: f32, // 1.0 / texture width
|
||||
inv_height: f32, // 1.0 / texture height
|
||||
_pad: f32,
|
||||
};
|
||||
```
|
||||
|
||||
### Avec HDR, avec MSAA (nouveau)
|
||||
```
|
||||
Scene → MSAA HDR (4×, Rgba16Float) ──resolve──→ HDR texture (Rgba16Float)
|
||||
+ MSAA depth (4×) → [Bloom] → TM → swapchain → present
|
||||
Un seul uniform partagé entre les 2 passes (CoC et Blur) — les valeurs sont
|
||||
identiques. Pas de ping-pong de buffers.
|
||||
|
||||
### D4 — Blur : disc 12-tap
|
||||
|
||||
Le blur utilise un pattern de 12 échantillons en disque (poisson-like),
|
||||
scallé par le CoC local :
|
||||
|
||||
```text
|
||||
· ·
|
||||
· ·
|
||||
· ·
|
||||
· · ·
|
||||
· ·
|
||||
· ·
|
||||
· ·
|
||||
```
|
||||
|
||||
**Principe** : MSAA s'insère **uniquement** entre le rasterizer et le premier
|
||||
consommateur de la texture de scène. Les post-processes (bloom, TM) voient
|
||||
toujours une texture single-sample.
|
||||
Chaque tap : `offset * coc_radius * texel_size`, pondéré uniformément (1/12).
|
||||
Le radius variable (par pixel) donne un bokeh naturel.
|
||||
|
||||
---
|
||||
> Pourquoi pas separable H+V comme bloom ? Le DoF produit un flou **circulaire**
|
||||
> (bokeh), pas un flou directionnel. Un disc blur single-pass est plus fidèle.
|
||||
> 12 taps × 1 texture = trivial GPU cost.
|
||||
|
||||
## Décisions de design
|
||||
### D5 — Textures
|
||||
|
||||
| # | Décision | Rationale |
|
||||
|---|----------|-----------|
|
||||
| D1 | `AppBuilder::with_msaa(count)` — opt-in, zero cost désactivé | Principe WSG : chaque effet est optionnel |
|
||||
| D2 | `sample_count` configurable (2, 4, 8) — default 4 | 4× est le bon rapport qualité/coût ; 8× pour du "max" |
|
||||
| D3 | Texture MSAA **offscreen** (jamais le swapchain en MSAA direct) | Uniformité : même code path que HDR, resize plus simple |
|
||||
| D4 | Depth buffer recréé en MSAA quand actif | Le depth doit avoir le même `sample_count` que le color |
|
||||
| D5 | Resolve via `RenderPassColorAttachment::resolve_target` | Natif wgpu, pas de shader supplémentaire |
|
||||
| D6 | **Aucun nouveau shader** | MSAA est une feature rasterizer, pas un post-process |
|
||||
| D7 | Format MSAA = format de la cible (Rgba16Float si HDR, surface format sinon) | Le resolve produit la même texture qu'avant |
|
||||
| D8 | Resize recrée les textures MSAA + depth | Même pattern que HDR resize |
|
||||
| D9 | Bloom/TM inchangés — ils lisent la texture résolue (single-sample) | Zéro impact sur les passes post |
|
||||
| D10 | `MsaaConfig { sample_count: u32 }` — public, re-exporté | API minimale, extensible |
|
||||
| Texture | Format | Taille | Quand allouée |
|
||||
|---------|--------|--------|---------------|
|
||||
| `coc_texture` | `R16Float` | full-res (w×h) | DoF actif |
|
||||
| `dof_output` | `Rgba16Float` | full-res (w×h) | DoF actif |
|
||||
|
||||
---
|
||||
Quand DoF est désactivé : **aucune** texture DoF n'est allouée. Zéro coût.
|
||||
|
||||
## API utilisateur
|
||||
### D6 — API publique
|
||||
|
||||
```rust
|
||||
use wsg_lib::prelude::*;
|
||||
|
||||
let app = AppBuilder::new()
|
||||
.title("MSAA Demo")
|
||||
.with_hdr(ToneMapper::Aces)
|
||||
.with_msaa(4) // ← 4 échantillons (2, 4, ou 8)
|
||||
.with_bloom(BloomConfig::default())
|
||||
.build()
|
||||
.await?;
|
||||
```
|
||||
|
||||
Sans `.with_msaa(...)` → comportement identique à aujourd'hui (0 échantillon overhead).
|
||||
|
||||
---
|
||||
|
||||
## Implémentation
|
||||
|
||||
### 24.1 — `MsaaConfig` (public)
|
||||
|
||||
Fichier : `lib/src/core/msaa.rs`
|
||||
|
||||
```rust
|
||||
/// Configuration MSAA. `sample_count` doit être 2, 4, ou 8
|
||||
/// (valeur supportée par le GPU — vérifiée à l'init).
|
||||
/// Configuration du Depth of Field.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct MsaaConfig {
|
||||
pub sample_count: u32,
|
||||
pub struct DoFConfig {
|
||||
/// Distance de focus (unités monde). L'image est nette à cette distance.
|
||||
pub focus_distance: f32,
|
||||
/// Intensité du flou (0.0 = aucun, 1.0 = max).
|
||||
pub aperture: f32,
|
||||
/// Radius maximum du flou en pixels.
|
||||
pub max_blur: f32,
|
||||
}
|
||||
|
||||
impl Default for MsaaCapable {
|
||||
fn default() -> Self {
|
||||
Self { sample_count: 4 }
|
||||
}
|
||||
impl DoFConfig {
|
||||
/// DoF standard : focus à `distance`, flou modéré.
|
||||
pub fn new(focus_distance: f32, aperture: f32, max_blur: f32) -> Self;
|
||||
|
||||
/// Preset cinématique : flou prononcé, max_blur=12px.
|
||||
pub fn cinematic(focus_distance: f32) -> Self;
|
||||
|
||||
/// Preset subtil : léger flou en arrière-plan, max_blur=6px.
|
||||
pub fn subtle(focus_distance: f32) -> Self;
|
||||
}
|
||||
```
|
||||
|
||||
### 24.2 — Champs `Renderer`
|
||||
|
||||
Ajouter à `Renderer` :
|
||||
**Builder** :
|
||||
```rust
|
||||
msaa_config: MsaaConfig, // toujours présent (sample_count=1 si désactivé)
|
||||
msaa_color_texture: Option<wgpu::Texture>, // Some si msaa active
|
||||
msaa_color_view: Option<wgpu::TextureView>,
|
||||
msaa_depth_texture: Option<wgpu::Texture>,
|
||||
msaa_depth_view: Option<wgpu::TextureView>,
|
||||
AppBuilder::with_dof(DoFConfig::cinematic(5.0))
|
||||
```
|
||||
|
||||
Quand `msaa_config.sample_count > 1` :
|
||||
- `msaa_color_texture` = texture `sample_count=N`, format = HDR ou surface
|
||||
- `msaa_depth_texture` = texture depth `sample_count=N`
|
||||
- Le render pass scène utilise ces views + `resolve_target`
|
||||
**Runtime** :
|
||||
```rust
|
||||
app.renderer_mut().set_dof(Some(DoFConfig::new(3.0, 0.5, 8.0)));
|
||||
app.renderer_mut().set_dof(None); // désactiver
|
||||
```
|
||||
|
||||
Quand `sample_count == 1` :
|
||||
- Tous les `Option` sont `None`
|
||||
- Le render pass utilise la texture/view existante (comportement actuel)
|
||||
### D7 — Pipeline integration
|
||||
|
||||
### 24.3 — Allocation à l'init (`Renderer::new`)
|
||||
Dans `Renderer::render_scene` :
|
||||
|
||||
```rust
|
||||
let sample_count = msaa_config.map(|c| c.sample_count).unwrap_or(1);
|
||||
|
||||
// Vérifier que le format supporte ce sample_count
|
||||
let formats = device.limits(); // ou surface.capabilities()
|
||||
// Pour le surface : surface.capabilities().formats
|
||||
// Pour l'offscreen : device.limits().max_color_attachments, etc.
|
||||
// En pratique : Rgba16Float et Rgba8Unorm supportent 4× partout.
|
||||
|
||||
if sample_count > 1 {
|
||||
let msaa_tex = device.create_texture(&TextureDescriptor {
|
||||
size: Extent3d { width, height, depth_or_array_layers: 1 },
|
||||
sample_count,
|
||||
dimension: Dimension::D2,
|
||||
format: target_format, // Rgba16Float ou surface format
|
||||
usage: TextureUsages::RENDER_ATTACHMENT,
|
||||
// Après bloom (ou après main pass si pas de bloom) :
|
||||
if let Some(dof) = &self.dof_pipeline {
|
||||
// 1. CoC pass
|
||||
let mut coc_pass = encoder.begin_render_pass(&RenderPassDescriptor {
|
||||
color_attachments: &[Some(RenderPassColorAttachment {
|
||||
view: &dof.coc_view,
|
||||
resolve_target: None,
|
||||
ops: ColorOps::ALL,
|
||||
format: TextureFormat::R16Float,
|
||||
..
|
||||
})],
|
||||
depth_stencil_attachment: None,
|
||||
..
|
||||
});
|
||||
// + depth MSAA
|
||||
}
|
||||
```
|
||||
coc_pass.set_pipeline(&dof.coc_pipeline);
|
||||
coc_pass.set_bind_group(0, &dof.coc_bind_group, &[]);
|
||||
coc_pass.draw(0, 3, 0, 1);
|
||||
drop(coc_pass);
|
||||
|
||||
### 24.4 — Render pass scène (modification `render_scene`)
|
||||
|
||||
Le render pass principal doit utiliser les views MSAA quand actif :
|
||||
|
||||
```rust
|
||||
// Déterminer la color attachment
|
||||
let (color_view, resolve_target) = if let Some(msaa_view) = &self.msaa_color_view {
|
||||
// MSAA actif : render dans MSAA, resolve vers la texture single
|
||||
let resolve = if self.hdr.is_some() {
|
||||
Some(self.hdr.as_ref().unwrap().view.clone()) // resolve → HDR tex
|
||||
} else {
|
||||
Some(view.clone()) // resolve → swapchain
|
||||
};
|
||||
(msaa_view.clone(), resolve)
|
||||
} else {
|
||||
// Pas de MSAA : comportement actuel
|
||||
let color_view = if let Some(hdr) = &self.hdr {
|
||||
hdr.view.clone()
|
||||
} else {
|
||||
view.clone()
|
||||
};
|
||||
(color_view, None)
|
||||
};
|
||||
|
||||
// Depth : MSAA ou single
|
||||
let depth_view = if let Some(msaa_depth) = &self.msaa_depth_view {
|
||||
msaa_depth.clone()
|
||||
} else {
|
||||
self.depth_view.clone()
|
||||
};
|
||||
|
||||
encoder.render_pass(RenderPassDescriptor {
|
||||
color_attachments: &[Some(RenderPassColorAttachment {
|
||||
view: &color_view,
|
||||
resolve_target: resolve_target.as_ref(),
|
||||
load_op: LoadOp::Clear,
|
||||
store_op: StoreOp::Store, // Store même en MSAA (wgpu gère le resolve)
|
||||
})],
|
||||
depth_stencil_attachment: Some(RenderPassDepthStencilAttachment {
|
||||
view: &depth_view,
|
||||
// 2. Blur pass
|
||||
let mut blur_pass = encoder.begin_render_pass(&RenderPassDescriptor {
|
||||
color_attachments: &[Some(RenderPassColorAttachment {
|
||||
view: &dof.output_view,
|
||||
resolve_target: None,
|
||||
ops: ColorOps::ALL,
|
||||
format: TextureFormat::Rgba16Float,
|
||||
..
|
||||
})],
|
||||
depth_stencil_attachment: None,
|
||||
..
|
||||
}),
|
||||
..
|
||||
});
|
||||
});
|
||||
blur_pass.set_pipeline(&dof.blur_pipeline);
|
||||
blur_pass.set_bind_group(0, &dof.blur_bind_group, &[]);
|
||||
blur_pass.draw(0, 3, 0, 1);
|
||||
drop(blur_pass);
|
||||
|
||||
// 3. TM lit dof_output au lieu de HDR
|
||||
// (re-pointer le bind group TM)
|
||||
}
|
||||
```
|
||||
|
||||
### 24.5 — Resize
|
||||
### D8 — Shaders
|
||||
|
||||
```rust
|
||||
fn resize(&mut self, device, width, height) {
|
||||
// ... resize depth existant ...
|
||||
|
||||
if self.msaa_config.sample_count > 1 {
|
||||
// Recréer MSAA color + depth
|
||||
self.msaa_color_texture = Some(create_msaa_texture(...));
|
||||
self.msaa_color_view = Some(...);
|
||||
self.msaa_depth_texture = Some(create_msaa_depth(...));
|
||||
self.msaa_depth_view = Some(...);
|
||||
#### `dof_coc.wgsl`
|
||||
|
||||
```wgsl
|
||||
// Vertex : fullscreen triangle (identique à TM/bloom)
|
||||
@vertex
|
||||
fn vs_main(@builtin(vertex_index) vid: u32) -> @builtin(position) vec4<f32> {
|
||||
// même triangle que TM : (-1,-1), (3,-1), (-1,3)
|
||||
}
|
||||
|
||||
struct DoFUniform {
|
||||
focus_distance: f32,
|
||||
aperture: f32,
|
||||
max_blur: f32,
|
||||
near: f32,
|
||||
far: f32,
|
||||
inv_width: f32,
|
||||
inv_height: f32,
|
||||
_pad: f32,
|
||||
};
|
||||
|
||||
@group(0) @binding(0) var<uniform> u: DoFUniform;
|
||||
@group(0) @binding(1) var depth_tex: texture_depth_2d;
|
||||
@group(0) @binding(2) var sampler: sampler;
|
||||
|
||||
@fragment
|
||||
fn fs_main(@builtin(position) pos: vec4<f32>) -> @location(0) f32 {
|
||||
let uv = pos.xy * vec2(u.inv_width, u.inv_height);
|
||||
let ndc_z = textureSample(depth_tex, sampler, uv);
|
||||
|
||||
// Linearize: NDC [0,1] → world distance
|
||||
let dist = u.near * u.far / (u.far - ndc_z * (u.far - u.near));
|
||||
|
||||
// CoC in pixels
|
||||
var coc = u.max_blur * u.aperture * abs(dist - u.focus_distance)
|
||||
/ max(u.focus_distance, 1e-4);
|
||||
coc = min(coc, u.max_blur);
|
||||
|
||||
// Edge case: depth = 1.0 (far plane) → no blur
|
||||
if (ndc_z >= 0.9999) { coc = 0.0; }
|
||||
|
||||
return coc;
|
||||
}
|
||||
```
|
||||
|
||||
#### `dof_blur.wgsl`
|
||||
|
||||
```wgsl
|
||||
// Vertex : fullscreen triangle (id)
|
||||
|
||||
struct DoFUniform { /* idem */ };
|
||||
|
||||
@group(0) @binding(0) var<uniform> u: DoFUniform;
|
||||
@group(0) @binding(1) var color_tex: texture_2d<f32>;
|
||||
@group(0) @binding(2) var coc_tex: texture_2d<f32>;
|
||||
@group(0) @binding(3) var sampler: sampler;
|
||||
|
||||
const TAPS: array<vec2<f32>, 12> = array<vec2<f32>, 12>(
|
||||
vec2(0.0, 0.0),
|
||||
vec2(0.0, 1.0), vec2(1.0, 0.0), vec2(0.0, -1.0), vec2(-1.0, 0.0),
|
||||
vec2(0.707, 0.707), vec2(0.707, -0.707),
|
||||
vec2(-0.707, 0.707), vec2(-0.707, -0.707),
|
||||
vec2(0.383, 0.924), vec2(-0.383, 0.924), vec2(0.383, -0.924),
|
||||
);
|
||||
|
||||
@fragment
|
||||
fn fs_main(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
|
||||
let uv = pos.xy * vec2(u.inv_width, u.inv_height);
|
||||
let coc = textureSample(coc_tex, sampler, uv).r;
|
||||
|
||||
if (coc < 0.5) {
|
||||
// Below 0.5px: no blur needed
|
||||
return textureSample(color_tex, sampler, uv);
|
||||
}
|
||||
|
||||
// HDR resize existant
|
||||
// Bloom resize existant
|
||||
|
||||
let radius = coc; // in pixels
|
||||
var sum = vec4<f32>(0.0);
|
||||
for (var i = 0u; i < 12u; i++) {
|
||||
let offset = TAPS[i] * radius * vec2(u.inv_width, u.inv_height);
|
||||
sum += textureSample(color_tex, sampler, uv + offset);
|
||||
}
|
||||
return sum / 12.0;
|
||||
}
|
||||
```
|
||||
|
||||
### 24.6 — Plomberie App/AppBuilder
|
||||
### D9 — Bind group layouts
|
||||
|
||||
**CoC pipeline** (3 bindings) :
|
||||
| Binding | Type | Description |
|
||||
|---------|------|-------------|
|
||||
| 0 | Uniform (32B) | DoF params |
|
||||
| 1 | Texture (depth) | Depth buffer de la scène |
|
||||
| 2 | Sampler | Linear, clamp |
|
||||
|
||||
**Blur pipeline** (4 bindings) :
|
||||
| Binding | Type | Description |
|
||||
|---------|------|-------------|
|
||||
| 0 | Uniform (32B) | DoF params |
|
||||
| 1 | Texture (color) | HDR/bloom color |
|
||||
| 2 | Texture (color) | CoC texture |
|
||||
| 3 | Sampler | Linear, clamp |
|
||||
|
||||
Chaque pipeline a **son propre** pipeline layout (règle wgpu 30).
|
||||
|
||||
### D10 — `DoFPipeline` struct
|
||||
|
||||
```rust
|
||||
// AppBuilder
|
||||
pub fn with_msaa(mut self, sample_count: u32) -> Self {
|
||||
assert!((2..=8).contains(&sample_count) && sample_count.is_power_of_two(),
|
||||
"sample_count must be 2, 4, or 8");
|
||||
self.msaa_config = Some(MsaaConfig { sample_count });
|
||||
self
|
||||
}
|
||||
pub(crate) struct DoFPipeline {
|
||||
// Textures
|
||||
coc_texture: Texture,
|
||||
coc_view: TextureView,
|
||||
output_texture: Texture,
|
||||
output_view: TextureView,
|
||||
|
||||
// App
|
||||
pub fn msaa_enabled(&self) -> bool { ... }
|
||||
pub fn set_msaa(&mut self, sample_count: u32) { ... } // nécessite resize
|
||||
// Sampler (shared between both passes)
|
||||
sampler: Sampler,
|
||||
|
||||
// Pipelines
|
||||
coc_pipeline: RenderPipeline,
|
||||
blur_pipeline: RenderPipeline,
|
||||
|
||||
// Uniform buffer (shared: same values for both passes)
|
||||
uniform_buffer: Buffer,
|
||||
|
||||
// Bind groups
|
||||
coc_bind_group: BindGroup,
|
||||
blur_bind_group: BindGroup,
|
||||
}
|
||||
```
|
||||
|
||||
Plomberie : `AppBuilder → App → AppRunner → Renderer::new(msaa_config)`
|
||||
Méthodes :
|
||||
- `DoFPipeline::new(device, width, height, depth_view, color_view)` → alloue tout
|
||||
- `DoFPipeline::update_uniform(&mut self, queue, config, near, far)` → écrit le buffer
|
||||
- `DoFPipeline::output_view(&self) -> &TextureView` → pour re-pointer le TM
|
||||
- `DoFPipeline::output_texture(&self) -> &Texture` → pour le bind group TM
|
||||
- `DoFPipeline::resize(...)` → recrée textures + bind groups
|
||||
|
||||
### 24.7 — Re-exports
|
||||
### D11 — Resize
|
||||
|
||||
`lib.rs` + `prelude.rs` : `pub use crate::core::MsaaConfig;`
|
||||
Dans `resize_depth` (ou équivalent) :
|
||||
```rust
|
||||
if let Some(dof) = &mut self.dof_pipeline {
|
||||
dof.resize(device, queue, new_w, new_h, &new_depth_view, &new_color_view);
|
||||
}
|
||||
```
|
||||
|
||||
### 24.8 — Exemple `msaa.rs`
|
||||
### D12 — Ordre des post-process
|
||||
|
||||
Scène simple (cube + sphere + ground) avec/without MSAA commutable à la runtime
|
||||
(clavier `M`). Camera orbitale pour voir les bords de près.
|
||||
```text
|
||||
Main pass → HDR
|
||||
→ Bloom (si actif) → bloom_composite
|
||||
→ DoF (si actif) → dof_output
|
||||
→ TM → surface
|
||||
```
|
||||
|
||||
Contrôles :
|
||||
- `M` — toggle MSAA (nécessite un resize/recréation des textures)
|
||||
- `R`/`1`/`2`/`3` — presets caméra
|
||||
- Drag/wheel — orbit/zoom
|
||||
DoF **après** bloom : le glow du bloom est aussi flouté par le DoF → plus naturel.
|
||||
|
||||
### 24.9 — Documentation
|
||||
### D13 — Compatibilité
|
||||
|
||||
- `docs/user/msaa.md` : activation, config, coûts, limitations
|
||||
- `docs/user/README.md` : section "Anti-aliasing"
|
||||
- `lib/examples/README.md` : entrée `msaa`
|
||||
- `docs/ROADMAP.md` : 6.4 → ✅
|
||||
| Avec | OK ? | Note |
|
||||
|------|------|------|
|
||||
| HDR | ✅ **requis** | DoF opère sur la texture HDR |
|
||||
| Bloom | ✅ | DoF après bloom (D12) |
|
||||
| MSAA | ✅ | Après resolve, DoF voit la texture single-sample |
|
||||
| Fog | ✅ | Fog est dans le main pass, DoF floute le résultat |
|
||||
| Culling | ✅ | Indépendant |
|
||||
|
||||
### D14 — `with_dof` sans `with_hdr` = no-op
|
||||
|
||||
Comme bloom, DoF nécessite HDR. `with_dof()` sans `with_hdr()` → warning + no-op.
|
||||
|
||||
---
|
||||
|
||||
## Coût GPU
|
||||
## Fichiers modifiés / créés
|
||||
|
||||
| Config | Coût relatif |
|
||||
|--------|:---:|
|
||||
| Sans MSAA | 1× |
|
||||
| MSAA 4× | ~1.3–1.5× (le rasterizer sur-échantillonne, le fill rate est partagé) |
|
||||
| MSAA 8× | ~1.5–2× |
|
||||
| Fichier | Action |
|
||||
|---------|--------|
|
||||
| `lib/src/core/dof.rs` | **NEW** — `DoFConfig` + `DoFPipeline` |
|
||||
| `lib/src/core/mod.rs` | + `pub mod dof;` + re-exports |
|
||||
| `lib/src/lib.rs` | + `pub use DoFConfig` |
|
||||
| `lib/src/prelude.rs` | + `DoFConfig` |
|
||||
| `lib/src/core/renderer.rs` | + `dof` field, `set_dof()`, render pass, resize |
|
||||
| `lib/src/app.rs` | + `with_dof()`, plumbage App/Builder/Runner |
|
||||
| `lib/src/shaders/dof_coc.wgsl` | **NEW** |
|
||||
| `lib/src/shaders/dof_blur.wgsl` | **NEW** |
|
||||
| `lib/tests/wgsl_validate.rs` | + 2 shaders DoF |
|
||||
| `lib/examples/dof.rs` | **NEW** |
|
||||
| `lib/examples/README.md` | + section DoF |
|
||||
| `docs/user/dof.md` | **NEW** |
|
||||
| `docs/user/README.md` | + ligne DoF |
|
||||
| `docs/ROADMAP.md` | 6.17 → ✅ |
|
||||
|
||||
MSAA est **beaucoup** moins coûteux qu'un post-process AA (FXAA, TAA) car le
|
||||
sur-coût est dans le rasterizer (edges only) et pas dans un blur fullscreen.
|
||||
Les post-processes (bloom, TM) ne sont **pas** affectés — ils tournent sur la
|
||||
texture résolue (1 échantillon/pixel).
|
||||
---
|
||||
|
||||
## Limitations / non-goals
|
||||
## Plan d'implémentation
|
||||
|
||||
- **Pas de TAA** (temporal AA) — nécessiterait un history buffer + motion vectors,
|
||||
bien plus complexe. MSAA 4× couvre 90% du besoin pour un lib "simple".
|
||||
- **Pas de MSAA sur les post-processes** — le bloom/TM lissent déjà l'image.
|
||||
- **Pas de coverage sampling** (DX12-only) — WebGPU expose seulement MSAA.
|
||||
- **Resize = recréation** des textures MSAA (pas de resize in-place).
|
||||
| # | Tâche | Dépend |
|
||||
|---|-------|--------|
|
||||
| 1 | `core/dof.rs` : `DoFConfig` + tests | — |
|
||||
| 2 | `core/mod.rs` + `lib.rs` + `prelude.rs` : exports | 1 |
|
||||
| 3 | `shaders/dof_coc.wgsl` + `shaders/dof_blur.wgsl` | — |
|
||||
| 4 | `tests/wgsl_validate.rs` : ajouter les 2 shaders | 3 |
|
||||
| 5 | `core/dof.rs` : `DoFPipeline` (textures, pipelines, BGL, bind groups) | 3 |
|
||||
| 6 | `core/renderer.rs` : fields + `new` + `set_dof` + `render_scene` + `resize` | 5 |
|
||||
| 7 | `app.rs` : `with_dof()` + plumbage | 6 |
|
||||
| 8 | `examples/dof.rs` | 6 |
|
||||
| 9 | Docs : examples README + user docs + ROADMAP | 8 |
|
||||
| 10 | Vérification : `cargo check` + tests + examples | all |
|
||||
|
||||
## Tests
|
||||
---
|
||||
|
||||
- [ ] `MsaaConfig` Default = 4
|
||||
- [ ] Validation `sample_count` (rejette 3, 5, 16)
|
||||
- [ ] Build avec `with_msaa(4)` + HDR + Bloom → compile
|
||||
- [ ] Exemple `msaa.rs` compile
|
||||
- [ ] WGSL validation inchangé (pas de nouveau shader)
|
||||
- [ ] Test unit : `Renderer::new` avec `msaa_config=Some(4)` ne panique pas
|
||||
(nécessite un device mock — peut être un test intégration ignoré)
|
||||
## Estimation
|
||||
|
||||
## Critères d'acceptation
|
||||
|
||||
1. `cargo check` 0 errors, 0 warnings
|
||||
2. `cargo test` tous verts
|
||||
3. `cargo run -p wsg-lib --example msaa` → bords lisses avec M, escaliers sans M
|
||||
4. `cargo run -p wsg-lib --example demo` → inchangé (pas de `with_msaa`)
|
||||
5. Combinaison HDR + Bloom + MSAA fonctionne (demo avec `.with_msaa(4)`)
|
||||
- **Effort** : Moyen (~200 lignes Rust + ~80 lignes WGSL)
|
||||
- **Risque** : Bas (pattern identique à bloom, 2 passes simples)
|
||||
- **Gain visuel** : ⭐⭐⭐ (effet cinématique immédiat)
|
||||
|
||||
+2
-2
@@ -70,11 +70,11 @@ Ce document est la **vue d'ensemble de progression**. Chaque étape a son DRAFT
|
||||
| 6.5 | **Normal mapping / PBR** (nouveau shader, tangent space, metalness-roughness) | ⭐⭐⭐ | Élevé | ⬜ |
|
||||
| 6.6 | **Cascaded Shadow Maps** (2–3 cascades + blend, plus de précision près de la camera) | ⭐⭐ | Élevé | ⬜ |
|
||||
| 6.7 | **SSAO** (ambient occlusion screen-space, depth + normal buffer) | ⭐⭐ | Élevé | ⬜ |
|
||||
| 6.13 | **Fog** (exponential / exponential² / height fog, paramètre par scène ou par matériau) | ⭐⭐⭐ | Faible | ⬜ |
|
||||
| 6.13 | **Fog** (exponential / exponential² / linear, paramètre par scène) | ⭐⭐⭐ | Faible | ✅ |
|
||||
| 6.14 | **Area lights** (rectangular area light, BRDF approx — specular + diffuse) | ⭐⭐⭐ | Élevé | ⬜ |
|
||||
| 6.15 | **Textured area lights** (area light avec texture d’émission, e.g. panneaux LED, néons) | ⭐⭐⭐ | Moyen | ⬜ |
|
||||
| 6.16 | **Volumetric lighting** (god rays / light scattering — radial blur ou ray-march 3D) | ⭐⭐⭐⭐ | Élevé | ⬜ |
|
||||
| 6.17 | **Depth of field** (post-process CoC : circle-of-confusion + bokeh blur) | ⭐⭐⭐ | Moyen | ⬜ |
|
||||
| 6.17 | **Depth of field** (post-process CoC : circle-of-confusion + bokeh blur) | ⭐⭐⭐ | Moyen | ✅ |
|
||||
|
||||
### Cibles techniques (refactoring)
|
||||
|
||||
|
||||
@@ -23,6 +23,8 @@ GPU graphics background is required.
|
||||
| [Mesh & primitives](mesh.md) | Procedural generators + file import (OBJ), feature-gated |
|
||||
| [HDR & tone mapping](hdr.md) | Offscreen float render + ACES/Reinhard, opt-in via `with_hdr` |
|
||||
| [MSAA (anti-aliasing)](msaa.md) | Multi-sample edge smoothing, opt-in via `with_msaa(4)` |
|
||||
| [Fog (distance)](fog.md) | Distance fog (3 modes), masks world edges, opt-in via `with_fog()` |
|
||||
| [DoF (depth of field)](dof.md) | Cinematic bokeh blur, focus plane, opt-in via `with_dof()` |
|
||||
| [GPU-driven rendering](gpu-driven.md) | GPU world matrices + indirect draws, opt-in frustum culling |
|
||||
| [Camera & input](camera-input.md) | Active camera, orbital controller, unified keyboard/mouse state |
|
||||
| [Examples](examples.md) | The 7 repo examples, the advanced `manual` workflow, adding your own example |
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
# Brouillard de distance (Fog)
|
||||
|
||||
## Principe
|
||||
|
||||
Le brouillard de distance fond les objets vers une couleur prédéfinie en fonction
|
||||
de leur distance à la caméra. C'est l'outil standard pour :
|
||||
|
||||
- **Masquer le bord du monde rendu** — illusion d'un monde infini (Skyrim, GTA, Minecraft)
|
||||
- **Donner de la profondeur** — effet atmosphérique naturel
|
||||
- **Camoufler les transitions** — chargement de tuiles, LOD pops
|
||||
|
||||
## Activation
|
||||
|
||||
```rust
|
||||
use wsg_lib::prelude::*;
|
||||
|
||||
let app = AppBuilder::new()
|
||||
.with_fog(FogConfig::exponential2([0.7, 0.75, 0.85], 0.06))
|
||||
.build()
|
||||
.await?;
|
||||
```
|
||||
|
||||
Sans `.with_fog()`, le brouillard est désactivé — **zéro coût GPU** (la branche
|
||||
shader est jamais prise).
|
||||
|
||||
## Modes
|
||||
|
||||
| Mode | Formule | Usage |
|
||||
|------|---------|-------|
|
||||
| `Linear` | `saturate((far - d) / (far - near))` | Cutoff net entre deux distances |
|
||||
| `Exponential` | `exp(-density × d)` | Brouillard naturel (forêt, lac) |
|
||||
| `Exponential2` | `exp(-density² × d²)` | Départ progressif, cutoff net — **idéal pour masquer** |
|
||||
|
||||
### Constructeurs
|
||||
|
||||
```rust
|
||||
// Linéaire : fondu entre near et far
|
||||
FogConfig::linear([0.7, 0.8, 0.9], 5.0, 50.0)
|
||||
|
||||
// Exponentiel : fondu naturel
|
||||
FogConfig::exponential([0.6, 0.7, 0.8], 0.03)
|
||||
|
||||
// Exponentiel² : masquage de bord de monde
|
||||
FogConfig::exponential2([0.7, 0.75, 0.85], 0.08)
|
||||
```
|
||||
|
||||
## Paramètres
|
||||
|
||||
| Champ | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `mode` | `FogMode` | Linéaire / Exponentiel / Exponential2 |
|
||||
| `color` | `[f32; 3]` | Couleur du brouillard (RGB, espace linéaire) |
|
||||
| `near` | `f32` | Distance début (mode linéaire uniquement) |
|
||||
| `far` | `f32` | Distance fin, brouillard complet (mode linéaire) |
|
||||
| `density` | `f32` | Densité (modes exp / exp²). Typique : 0.01–0.3 |
|
||||
|
||||
### Choisir la couleur
|
||||
|
||||
La couleur du brouillard **doit correspondre à la couleur du ciel/clear color**
|
||||
pour un effet "monde infini" seamless. Avec HDR + ACES, utiliser des valeurs
|
||||
linéaires cohérentes avec le tone mapping.
|
||||
|
||||
### Choisir la densité (exp²)
|
||||
|
||||
Pour masquer le bord du monde à une distance `D` :
|
||||
|
||||
```
|
||||
density ≈ 2.0 / D
|
||||
```
|
||||
|
||||
Exemples :
|
||||
- Monde visible jusqu'à 25 unités → `density = 0.08`
|
||||
- Monde visible jusqu'à 50 unités → `density = 0.04`
|
||||
- Monde visible jusqu'à 100 unités → `density = 0.02`
|
||||
|
||||
## Changement à l'exécution
|
||||
|
||||
```rust
|
||||
// Dans update() :
|
||||
if key_pressed(KeyCode::Digit1) {
|
||||
app.renderer_mut().set_fog(Some(FogConfig::linear([0.7, 0.8, 0.9], 5.0, 30.0)));
|
||||
}
|
||||
if key_pressed(KeyCode::Digit4) {
|
||||
app.renderer_mut().set_fog(None); // désactiver
|
||||
}
|
||||
```
|
||||
|
||||
Le changement prend effet au frame suivant.
|
||||
|
||||
## Pipeline
|
||||
|
||||
```text
|
||||
Main pass (shader fragment)
|
||||
↓
|
||||
Lighting → final_rgb
|
||||
↓
|
||||
FOG: mix(final_rgb, fog_color, 1 - fog_factor) ← ici
|
||||
↓
|
||||
→ HDR texture / swapchain
|
||||
↓
|
||||
(Bloom) → Tone Mapping → surface
|
||||
```
|
||||
|
||||
Le brouillard s'applique **avant** le tone mapping : les valeurs HDR restent
|
||||
non clampées, et le TM applique la courbe ACES/Reinhard au résultat déjà
|
||||
brouillé. Résultat : le brouillard est perceptuellement cohérent.
|
||||
|
||||
## Compatibilité
|
||||
|
||||
| Avec | OK ? | Note |
|
||||
|------|------|------|
|
||||
| HDR + TM | ✅ | Fog avant TM (recommandé) |
|
||||
| Bloom | ✅ | Le bloom extrait les zones brillantes du résultat post-fog |
|
||||
| MSAA | ✅ | Indépendant (rasterizer vs fragment shader) |
|
||||
| Culling GPU | ✅ | Indépendant (culling décide quoi dessiner, fog décide la couleur) |
|
||||
| Shadows | ✅ | L'ombre est calculée avant le fog |
|
||||
|
||||
## Limitations (v1)
|
||||
|
||||
- **Scene-level uniquement** : un seul brouillard pour toute la scène.
|
||||
Un brouillard par matériau nécessiterait un paramètre additionnel dans le
|
||||
bind group par objet.
|
||||
- **Distance euclidienne** : pas de brouillard volumétrique ni directionnel.
|
||||
- **Couleur fixe** : pas de gradient de couleur avec la distance.
|
||||
|
||||
## Exemple
|
||||
|
||||
Voir `examples/fog.rs` : 15 cubes en rangée + 5 sphères sur un plan 80×80,
|
||||
avec commutation runtime entre les 3 modes.
|
||||
|
||||
```sh
|
||||
cargo run -p wsg-lib --example fog --features "all-prims"
|
||||
```
|
||||
@@ -32,3 +32,7 @@ pollster = { version="1.0.1", features = ["macro"] }
|
||||
# Étape 10 (Textures, DRAFT D3) : décodage d'images (PNG/JPEG) pour charger des textures diffuses.
|
||||
# default-features = false pour n'emporter que les codecs utiles (plus petit arbre de compilation).
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }
|
||||
|
||||
[[example]]
|
||||
name = "import"
|
||||
required-features = ["import-obj"]
|
||||
|
||||
@@ -18,6 +18,7 @@ cargo run -p wsg-lib --example <nom>
|
||||
| `shadow` | Shadow mapping (ombre portée directionnelle) |
|
||||
| `culling` | Culling GPU-driven (grille 15×15, objets hors frustum ignorés) |
|
||||
| `msaa` | MSAA 4× (anti-aliasing multi-échantillons, arêtes lisses) |
|
||||
| `fog` | Brouillard de distance (3 modes : linéaire, exp, exp²) |
|
||||
| `manual` | Workflow bas niveau (Context + Renderer + PipelineCache) |
|
||||
| `import` | Import de fichier OBJ (non graphique, stdout) |
|
||||
|
||||
@@ -251,6 +252,44 @@ identique, seules les arêtes diffèrent (escaler vs lisse).
|
||||
|
||||
---
|
||||
|
||||
## `fog` — Brouillard de distance
|
||||
|
||||
Démontre les 3 modes de brouillard : **linéaire**, **exponentiel**, **exponentiel²**.
|
||||
La scène contient une rangée de cubes qui s'éloignent et des sphères dispersées sur
|
||||
un grand plan au sol. Le brouillard fond les objets vers une couleur de fond,
|
||||
créant l'illusion d'un monde infini.
|
||||
|
||||
```sh
|
||||
cargo run -p wsg-lib --example fog --features "all-prims"
|
||||
```
|
||||
|
||||
**Touches** : `1` = linéaire, `2` = exp, `3` = exp², `4` = désactivé, `R` = reset.
|
||||
|
||||
> Le brouillard est appliqué dans le shader fragment principal (après l'éclairage,
|
||||
> avant le tone mapping). Il utilise la distance euclidienne du fragment à la caméra.
|
||||
|
||||
---
|
||||
|
||||
## `dof` — Depth of Field (bokeh cinématique)
|
||||
|
||||
Démontre le flou de profondeur de champ : un objet au centre reste net tandis que
|
||||
le premier et arrière-plan se flouent selon leur distance au plan de mise au point.
|
||||
Crée un effet d'attention naturelle (type cinématique).
|
||||
|
||||
La scène contient un cube de focus au centre, des sphères en premier plan (proches)
|
||||
et des cubes en arrière-plan (loin), sur un plan au sol.
|
||||
|
||||
```sh
|
||||
cargo run -p wsg-lib --example dof --features "all-prims"
|
||||
```
|
||||
|
||||
**Touches** : `1` = cinématique, `2` = subtil, `3` = focus 2m, `4` = focus 10m, `5` = off, `R` = reset.
|
||||
|
||||
> DoF opère en HDR linéaire (après bloom, avant tone mapping). Deux passes :
|
||||
> CoC (depth → rayon de flou par pixel) puis blur disque 12-taps à rayon variable.
|
||||
|
||||
---
|
||||
|
||||
## `manual` — Workflow bas niveau
|
||||
|
||||
Démontre l'API **sans** la façade `App` : utilisation directe de `Context`,
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
//! # Depth of Field Example (Étape 26)
|
||||
//!
|
||||
//! Demonstrates cinematic DoF: a row of cubes receding into the distance,
|
||||
//! with the focus plane at a configurable depth. Cubes at the focus distance
|
||||
//! stay sharp; those closer or farther blur proportionally.
|
||||
//!
|
||||
//! ## Pipeline
|
||||
//! DoF operates in linear HDR space **after** bloom and **before** tone mapping:
|
||||
//! 1. CoC pass: reads the depth buffer, linearizes to world distance, computes
|
||||
//! per-pixel blur radius.
|
||||
//! 2. Blur pass: 12-tap disc blur with variable radius (from CoC), producing
|
||||
//! natural circular bokeh.
|
||||
//!
|
||||
//! ## Controls
|
||||
//! | Key | Action |
|
||||
//! |-----|--------|
|
||||
//! | Drag (LMB) | Orbit camera |
|
||||
//! | Wheel | Zoom |
|
||||
//! | `1` | Cinematic preset (focus=8m, strong blur) |
|
||||
//! | `2` | Subtle preset (focus=8m, gentle blur) |
|
||||
//! | `3` | Focus at 3m (near cubes sharp, far blurred) |
|
||||
//! | `4` | Focus at 15m (far cubes sharp, near blurred) |
|
||||
//! | `5` | DoF OFF |
|
||||
//! | `R` | Reset camera |
|
||||
//!
|
||||
//! ## Build & Run
|
||||
//! ```sh
|
||||
//! cargo run -p wsg-lib --example dof --features "all-prims"
|
||||
//! ```
|
||||
|
||||
use glam::Vec3;
|
||||
use winit::event::MouseButton;
|
||||
use winit::keyboard::KeyCode;
|
||||
use wsg_lib::app::AppBuilder;
|
||||
use wsg_lib::camera::CameraController;
|
||||
use wsg_lib::core::{DoFConfig, ToneMapper, Transform};
|
||||
use wsg_lib::mesh::{cube, icosphere, plane};
|
||||
use wsg_lib::AppHandler;
|
||||
use wsg_lib::utils::WsgError;
|
||||
|
||||
struct DoFDemo {
|
||||
camera: CameraController,
|
||||
}
|
||||
|
||||
impl AppHandler for DoFDemo {
|
||||
fn setup(&mut self, app: &mut wsg_lib::App) {
|
||||
app.scene
|
||||
.register_shader("standard", wsg_lib::utils::STANDARD_SHADER_PATH)
|
||||
.unwrap();
|
||||
|
||||
// Ground plane.
|
||||
app.scene
|
||||
.create_mesh("ground_mesh", plane(80.0, 80.0, 1, 1), None)
|
||||
.unwrap();
|
||||
app.scene
|
||||
.add_entity_with_transform(
|
||||
"ground",
|
||||
"ground_mesh",
|
||||
Transform::identity(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Row of cubes receding along -Z (distance ≈ 2 to 25 from camera at dist=8).
|
||||
app.scene
|
||||
.create_mesh("cube_mesh", cube(1.0), None)
|
||||
.unwrap();
|
||||
|
||||
for i in 0..20 {
|
||||
let z = 3.0 - i as f32 * 1.5; // from z=3 (close) to z=-25.5 (far)
|
||||
let mut tf = Transform::identity();
|
||||
tf.translation = Vec3::new(0.0, 0.5, z);
|
||||
app.scene
|
||||
.add_entity_with_transform(&format!("cube_{i}"), "cube_mesh", tf)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// A few spheres scattered to the sides for visual interest.
|
||||
app.scene
|
||||
.create_mesh("sphere_mesh", icosphere(0.7, 3), None)
|
||||
.unwrap();
|
||||
let sphere_positions = [
|
||||
Vec3::new(2.5, 0.7, -2.0),
|
||||
Vec3::new(-3.0, 0.7, -6.0),
|
||||
Vec3::new(3.5, 0.7, -10.0),
|
||||
Vec3::new(-2.0, 0.7, -14.0),
|
||||
Vec3::new(2.0, 0.7, -18.0),
|
||||
];
|
||||
for (i, pos) in sphere_positions.iter().enumerate() {
|
||||
let mut tf = Transform::identity();
|
||||
tf.translation = *pos;
|
||||
app.scene
|
||||
.add_entity_with_transform(&format!("sphere_{i}"), "sphere_mesh", tf)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// Directional light.
|
||||
let light_dir = Vec3::new(-0.4, -1.0, -0.3).normalize();
|
||||
app.scene
|
||||
.add_directional_light(light_dir, [1.0, 0.95, 0.85], 1.2)
|
||||
.unwrap();
|
||||
app.scene.set_ambient([0.08, 0.08, 0.1]);
|
||||
|
||||
// Camera — positioned to look down the row of cubes.
|
||||
self.camera.yaw = 0.0;
|
||||
self.camera.pitch = 0.1;
|
||||
self.camera.distance = 8.0;
|
||||
self.camera.target = Vec3::new(0.0, 0.5, -8.0);
|
||||
self.camera.apply_to(app.scene.camera_mut());
|
||||
|
||||
eprintln!("[DoF] Initial: Cinematic (focus=8m, aperture=0.3, max_blur=12)");
|
||||
eprintln!("[DoF] Keys: 1=cinematic 2=subtle 3=focus 3m 4=focus 15m 5=off R=reset");
|
||||
}
|
||||
|
||||
fn update(&mut self, app: &mut wsg_lib::App) {
|
||||
// Orbit camera.
|
||||
let (dx, dy) = app.input.mouse_delta();
|
||||
if app.input.mouse_button_held(MouseButton::Left) {
|
||||
self.camera.orbit(dx, dy);
|
||||
}
|
||||
let (_, sy) = app.input.scroll_delta();
|
||||
self.camera.zoom(sy);
|
||||
|
||||
// DoF presets.
|
||||
if app.input.key_pressed(KeyCode::Digit1) {
|
||||
app.renderer_mut()
|
||||
.set_dof(Some(DoFConfig::cinematic(8.0)));
|
||||
eprintln!("[DoF] → Cinematic (focus=8m, aperture=0.3, max_blur=12)");
|
||||
}
|
||||
if app.input.key_pressed(KeyCode::Digit2) {
|
||||
app.renderer_mut()
|
||||
.set_dof(Some(DoFConfig::subtle(8.0)));
|
||||
eprintln!("[DoF] → Subtle (focus=8m, aperture=0.1, max_blur=8)");
|
||||
}
|
||||
if app.input.key_pressed(KeyCode::Digit3) {
|
||||
app.renderer_mut()
|
||||
.set_dof(Some(DoFConfig::new(3.0, 0.3, 12.0)));
|
||||
eprintln!("[DoF] → Focus 3m (near sharp, far blurred)");
|
||||
}
|
||||
if app.input.key_pressed(KeyCode::Digit4) {
|
||||
app.renderer_mut()
|
||||
.set_dof(Some(DoFConfig::new(15.0, 0.3, 12.0)));
|
||||
eprintln!("[DoF] → Focus 15m (far sharp, near blurred)");
|
||||
}
|
||||
if app.input.key_pressed(KeyCode::Digit5) {
|
||||
app.renderer_mut().set_dof(None);
|
||||
eprintln!("[DoF] → OFF");
|
||||
}
|
||||
if app.input.key_pressed(KeyCode::KeyR) {
|
||||
self.camera.yaw = 0.0;
|
||||
self.camera.pitch = 0.1;
|
||||
self.camera.distance = 8.0;
|
||||
self.camera.target = Vec3::new(0.0, 0.5, -8.0);
|
||||
}
|
||||
|
||||
self.camera.apply_to(app.scene.camera_mut());
|
||||
}
|
||||
|
||||
fn render(&mut self, app: &mut wsg_lib::App, frame: &wsg_lib::core::Frame) {
|
||||
app.render_scene(frame.view());
|
||||
}
|
||||
}
|
||||
|
||||
#[pollster::main]
|
||||
async fn main() -> Result<(), WsgError> {
|
||||
let app = AppBuilder::new()
|
||||
.title("WSG — Depth of Field (Étape 26)")
|
||||
.size(1280, 720)
|
||||
.with_hdr(ToneMapper::Aces)
|
||||
.with_dof(DoFConfig::cinematic(8.0))
|
||||
.build()
|
||||
.await?;
|
||||
app.run(DoFDemo {
|
||||
camera: CameraController::default(),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
//! # Fog Example (Étape 25)
|
||||
//!
|
||||
//! Demonstrates distance fog: objects fade into the fog color as they recede,
|
||||
//! creating the illusion of an infinite world (Skyrim/GTA pattern).
|
||||
//!
|
||||
//! The scene has a row of cubes receding into the distance and scattered spheres,
|
||||
//! all sitting on a large ground plane. Switch fog modes with number keys to
|
||||
//! compare the three attenuation curves.
|
||||
//!
|
||||
//! ## Pipeline
|
||||
//! Fog is applied in the main pass fragment shader (after lighting, before tone
|
||||
//! mapping). It uses the fragment's world-space distance to the camera and
|
||||
//! blends the final color toward `fog_color`.
|
||||
//!
|
||||
//! ## Controls
|
||||
//! | Key | Action |
|
||||
//! |-----|--------|
|
||||
//! | Drag (LMB) | Orbit camera |
|
||||
//! | Wheel | Zoom |
|
||||
//! | `1` | Linear fog (near=5, far=30) |
|
||||
//! | `2` | Exponential fog (density=0.04) |
|
||||
//! | `3` | Exponential² fog (density=0.06) — best for masking |
|
||||
//! | `4` | Fog OFF |
|
||||
//! | `R` | Reset camera |
|
||||
//!
|
||||
//! ## Build & Run
|
||||
//! ```sh
|
||||
//! cargo run -p wsg-lib --example fog --features "all-prims"
|
||||
//! ```
|
||||
|
||||
use glam::Vec3;
|
||||
use winit::event::MouseButton;
|
||||
use winit::keyboard::KeyCode;
|
||||
use wsg_lib::app::AppBuilder;
|
||||
use wsg_lib::camera::CameraController;
|
||||
use wsg_lib::core::{FogConfig, ToneMapper, Transform};
|
||||
use wsg_lib::mesh::{cube, icosphere, plane};
|
||||
use wsg_lib::AppHandler;
|
||||
use wsg_lib::utils::WsgError;
|
||||
|
||||
struct FogDemo {
|
||||
camera: CameraController,
|
||||
}
|
||||
|
||||
impl AppHandler for FogDemo {
|
||||
fn setup(&mut self, app: &mut wsg_lib::App) {
|
||||
app.scene
|
||||
.register_shader("standard", wsg_lib::utils::STANDARD_SHADER_PATH)
|
||||
.unwrap();
|
||||
|
||||
// Large ground plane — will fade into fog at distance.
|
||||
app.scene
|
||||
.create_mesh("ground_mesh", plane(80.0, 80.0, 1, 1), None)
|
||||
.unwrap();
|
||||
app.scene.add_entity("ground", "ground_mesh").unwrap();
|
||||
|
||||
// Row of cubes receding into the distance.
|
||||
app.scene
|
||||
.create_mesh("cube_mesh", cube(1.0), None)
|
||||
.unwrap();
|
||||
for i in 0..15 {
|
||||
let z = -2.0 - i as f32 * 2.5;
|
||||
let mut tf = Transform::identity();
|
||||
tf.translation = Vec3::new(0.0, 0.5, z);
|
||||
app.scene
|
||||
.add_entity_with_transform(&format!("cube_{i}"), "cube_mesh", tf)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// Scattered spheres at various distances.
|
||||
app.scene
|
||||
.create_mesh("sphere_mesh", icosphere(0.8, 3), None)
|
||||
.unwrap();
|
||||
let positions = [
|
||||
Vec3::new(3.0, 0.8, -5.0),
|
||||
Vec3::new(-4.0, 0.8, -10.0),
|
||||
Vec3::new(5.0, 0.8, -15.0),
|
||||
Vec3::new(-3.0, 0.8, -20.0),
|
||||
Vec3::new(0.0, 0.8, -30.0),
|
||||
];
|
||||
for (i, pos) in positions.iter().enumerate() {
|
||||
let mut tf = Transform::identity();
|
||||
tf.translation = *pos;
|
||||
app.scene
|
||||
.add_entity_with_transform(&format!("sphere_{i}"), "sphere_mesh", tf)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// Directional light.
|
||||
let light_dir = Vec3::new(-0.5, -1.0, -0.3).normalize();
|
||||
app.scene
|
||||
.add_directional_light(light_dir, [1.0, 0.95, 0.85], 1.2)
|
||||
.unwrap();
|
||||
app.scene.set_ambient([0.08, 0.08, 0.1]);
|
||||
|
||||
// Camera — positioned to look down the row of cubes.
|
||||
self.camera.yaw = 0.0;
|
||||
self.camera.pitch = 0.15;
|
||||
self.camera.distance = 8.0;
|
||||
self.camera.target = Vec3::new(0.0, 0.5, -8.0);
|
||||
self.camera.apply_to(app.scene.camera_mut());
|
||||
|
||||
// Print initial fog status.
|
||||
eprintln!("[Fog] Initial: Exponential² (density=0.06)");
|
||||
eprintln!("[Fog] Keys: 1=linear 2=exp 3=exp² 4=off R=reset");
|
||||
}
|
||||
|
||||
fn update(&mut self, app: &mut wsg_lib::App) {
|
||||
// Orbit camera.
|
||||
let (dx, dy) = app.input.mouse_delta();
|
||||
if app.input.mouse_button_held(MouseButton::Left) {
|
||||
self.camera.orbit(dx, dy);
|
||||
}
|
||||
let (_, sy) = app.input.scroll_delta();
|
||||
self.camera.zoom(sy);
|
||||
|
||||
// Fog mode switching.
|
||||
if app.input.key_pressed(KeyCode::Digit1) {
|
||||
app.renderer_mut()
|
||||
.set_fog(Some(FogConfig::linear([0.7, 0.75, 0.85], 5.0, 30.0)));
|
||||
eprintln!("[Fog] → Linear (near=5, far=30)");
|
||||
}
|
||||
if app.input.key_pressed(KeyCode::Digit2) {
|
||||
app.renderer_mut()
|
||||
.set_fog(Some(FogConfig::exponential([0.7, 0.75, 0.85], 0.04)));
|
||||
eprintln!("[Fog] → Exponential (density=0.04)");
|
||||
}
|
||||
if app.input.key_pressed(KeyCode::Digit3) {
|
||||
app.renderer_mut()
|
||||
.set_fog(Some(FogConfig::exponential2([0.7, 0.75, 0.85], 0.06)));
|
||||
eprintln!("[Fog] → Exponential² (density=0.06)");
|
||||
}
|
||||
if app.input.key_pressed(KeyCode::Digit4) {
|
||||
app.renderer_mut().set_fog(None);
|
||||
eprintln!("[Fog] → OFF");
|
||||
}
|
||||
if app.input.key_pressed(KeyCode::KeyR) {
|
||||
self.camera.yaw = 0.0;
|
||||
self.camera.pitch = 0.15;
|
||||
self.camera.distance = 8.0;
|
||||
}
|
||||
|
||||
self.camera.apply_to(app.scene.camera_mut());
|
||||
}
|
||||
|
||||
fn render(&mut self, app: &mut wsg_lib::App, frame: &wsg_lib::core::Frame) {
|
||||
app.render_scene(frame.view());
|
||||
}
|
||||
}
|
||||
|
||||
#[pollster::main]
|
||||
async fn main() -> Result<(), WsgError> {
|
||||
let app = AppBuilder::new()
|
||||
.title("WSG — Fog (3 modes)")
|
||||
.size(1024, 640)
|
||||
.with_fog(FogConfig::exponential2([0.7, 0.75, 0.85], 0.06))
|
||||
.with_hdr(ToneMapper::Aces)
|
||||
.build()
|
||||
.await?;
|
||||
app.run(FogDemo {
|
||||
camera: CameraController::default(),
|
||||
})
|
||||
}
|
||||
@@ -63,7 +63,7 @@ impl ApplicationHandler for App {
|
||||
|
||||
// Flat 2D rendering: `standard` in unlit mode (the frame+object bind groups are set by
|
||||
// draw_entity, the default frame matrix is the identity → NDC positions unchanged).
|
||||
let mut renderer = Renderer::new(&context, format, 800, 600, &ShadowConfig::default(), None, None, None);
|
||||
let mut renderer = Renderer::new(&context, format, 800, 600, &ShadowConfig::default(), None, None, None, None, None);
|
||||
renderer.set_unlit(true);
|
||||
|
||||
// 3. Material: uses renderer.device() and renderer.format()
|
||||
|
||||
+38
-1
@@ -73,6 +73,10 @@ pub struct App {
|
||||
/// MSAA configuration (Étape 24). `None` = no MSAA (default, zero overhead);
|
||||
/// `Some(config)` activates multi-sample anti-aliasing.
|
||||
msaa: Option<MsaaConfig>,
|
||||
/// Fog configuration (Étape 25). `None` = no fog (default, zero overhead).
|
||||
fog: Option<super::core::FogConfig>,
|
||||
/// DoF configuration (Étape 26). `None` = no DoF (default, zero overhead). Requires HDR.
|
||||
dof: Option<super::core::DoFConfig>,
|
||||
/// Winit event loop for window management. Set to None after run() consumes it.
|
||||
event_loop: Option<EventLoop<()>>, // On met en Option pour pouvoir faire .take() facilement
|
||||
/// GPU hardware context — owns Instance, Surface, Adapter, Device, Queue lifecycle.
|
||||
@@ -141,6 +145,8 @@ impl App {
|
||||
bloom_config: self.bloom_config.clone(),
|
||||
exposure: self.exposure,
|
||||
msaa: self.msaa.clone(),
|
||||
fog: self.fog.clone(),
|
||||
dof: self.dof.clone(),
|
||||
handler,
|
||||
app: None,
|
||||
};
|
||||
@@ -246,6 +252,10 @@ pub struct AppBuilder {
|
||||
exposure: f32,
|
||||
/// MSAA configuration (Étape 24). `None` = no MSAA (default).
|
||||
msaa: Option<MsaaConfig>,
|
||||
/// Fog configuration (Étape 25). `None` = no fog (default).
|
||||
fog: Option<super::core::FogConfig>,
|
||||
/// DoF configuration (Étape 26). `None` = no DoF (default). Requires HDR.
|
||||
dof: Option<super::core::DoFConfig>,
|
||||
}
|
||||
|
||||
impl AppBuilder {
|
||||
@@ -262,6 +272,8 @@ impl AppBuilder {
|
||||
bloom_config: None,
|
||||
exposure: 1.0,
|
||||
msaa: None,
|
||||
fog: None,
|
||||
dof: None,
|
||||
}
|
||||
}
|
||||
/// Sets the window title to display in the OS taskbar/window decorations.
|
||||
@@ -327,6 +339,23 @@ impl AppBuilder {
|
||||
}
|
||||
self
|
||||
}
|
||||
/// Enables distance fog (Étape 25). Fades objects into `config.color` based on their
|
||||
/// distance from the camera. Use `FogConfig::exponential2(color, density)` to mask
|
||||
/// the edge of the rendered world. Zero cost when not called.
|
||||
pub fn with_fog(mut self, config: super::core::FogConfig) -> Self {
|
||||
self.fog = Some(config);
|
||||
self
|
||||
}
|
||||
/// Enables Depth of Field (Étape 26). Blurs pixels based on their distance from the
|
||||
/// focus plane, creating a cinematic bokeh effect. **Requires HDR** (`with_hdr`):
|
||||
/// without it, the DoF is silently ignored with a warning. Zero cost when not called.
|
||||
pub fn with_dof(mut self, config: super::core::DoFConfig) -> Self {
|
||||
if self.hdr.is_none() {
|
||||
eprintln!("[wsg] Warning: with_dof() requires with_hdr() — DoF ignored.");
|
||||
}
|
||||
self.dof = Some(config);
|
||||
self
|
||||
}
|
||||
/// Builds the configured `App` instance: creates the event loop and stores the window
|
||||
/// configuration. The GPU context, window and renderer are created later, when the event loop
|
||||
/// is resumed (inside `App::run`), because winit 0.30 only allows window creation in that phase.
|
||||
@@ -346,6 +375,8 @@ impl AppBuilder {
|
||||
bloom_config: self.bloom_config,
|
||||
exposure: self.exposure,
|
||||
msaa: self.msaa,
|
||||
fog: self.fog,
|
||||
dof: self.dof,
|
||||
event_loop: Some(event_loop),
|
||||
context: None,
|
||||
renderer: None,
|
||||
@@ -376,6 +407,10 @@ struct AppRunner<H: AppHandler> {
|
||||
exposure: f32,
|
||||
/// MSAA config (Étape 24); passed to `Renderer::new` in `resumed`.
|
||||
msaa: Option<MsaaConfig>,
|
||||
/// Fog config (Étape 25); passed to `Renderer::new` in `resumed`.
|
||||
fog: Option<super::core::FogConfig>,
|
||||
/// DoF config (Étape 26); passed to `Renderer::new` in `resumed`. Only active with HDR.
|
||||
dof: Option<super::core::DoFConfig>,
|
||||
/// The user-provided game logic.
|
||||
handler: H,
|
||||
/// The fully-built App facade, populated on the first `resumed` event.
|
||||
@@ -409,7 +444,7 @@ impl<H: AppHandler> ApplicationHandler for AppRunner<H> {
|
||||
.expect("surface configuration failed");
|
||||
let device = Arc::new(context.device.clone());
|
||||
let renderer =
|
||||
Renderer::new(&context, format, self.width, self.height, &self.shadow_config, self.hdr, self.bloom_config.clone(), self.msaa.clone());
|
||||
Renderer::new(&context, format, self.width, self.height, &self.shadow_config, self.hdr, self.bloom_config.clone(), self.msaa.clone(), self.fog.clone(), self.dof.clone());
|
||||
// Step 15, D8: apply the culling flag (off by default — non-regression).
|
||||
renderer.set_culling(self.culling);
|
||||
|
||||
@@ -438,6 +473,8 @@ impl<H: AppHandler> ApplicationHandler for AppRunner<H> {
|
||||
bloom_config: self.bloom_config.clone(),
|
||||
exposure: self.exposure,
|
||||
msaa: self.msaa.clone(),
|
||||
fog: self.fog.clone(),
|
||||
dof: self.dof.clone(),
|
||||
event_loop: None,
|
||||
context: Some(context),
|
||||
renderer: Some(renderer),
|
||||
|
||||
@@ -0,0 +1,570 @@
|
||||
//! Depth of Field (DoF) configuration (Étape 26).
|
||||
//!
|
||||
//! DoF simulates camera lens behavior: objects at the focus distance are sharp,
|
||||
//! everything else is progressively blurred. This is a post-process effect that
|
||||
//! operates on the HDR texture + depth buffer before tone mapping.
|
||||
//!
|
||||
//! **Opt-in**: when no `DoFConfig` is set, no DoF textures are allocated and the
|
||||
//! pipeline cost is zero.
|
||||
|
||||
/// Depth of Field configuration.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct DoFConfig {
|
||||
/// Focus distance in world units. The image is perfectly sharp at this distance.
|
||||
pub focus_distance: f32,
|
||||
/// Blur intensity: 0.0 = no blur, 1.0 = maximum. Scales the CoC calculation.
|
||||
pub aperture: f32,
|
||||
/// Maximum blur radius in pixels. Clamps the CoC to prevent excessive blur.
|
||||
pub max_blur: f32,
|
||||
}
|
||||
|
||||
impl DoFConfig {
|
||||
/// Creates a custom DoF configuration.
|
||||
///
|
||||
/// - `focus_distance`: world distance where the image is sharp
|
||||
/// - `aperture`: blur intensity (0.0–1.0)
|
||||
/// - `max_blur`: maximum blur radius in pixels
|
||||
pub fn new(focus_distance: f32, aperture: f32, max_blur: f32) -> Self {
|
||||
Self {
|
||||
focus_distance,
|
||||
aperture: aperture.clamp(0.0, 1.0),
|
||||
max_blur: max_blur.max(0.0),
|
||||
}
|
||||
}
|
||||
|
||||
/// Cinematic preset: gradual blur building up to 12px at the extremes.
|
||||
/// Good for cutscenes and character close-ups.
|
||||
pub fn cinematic(focus_distance: f32) -> Self {
|
||||
Self::new(focus_distance, 0.3, 12.0)
|
||||
}
|
||||
|
||||
/// Subtle preset: very gentle blur, 8px max radius.
|
||||
/// Good for gameplay with a hint of depth separation.
|
||||
pub fn subtle(focus_distance: f32) -> Self {
|
||||
Self::new(focus_distance, 0.1, 8.0)
|
||||
}
|
||||
|
||||
/// Packs the config into the (fog-style) two vec4 uniform layout.
|
||||
/// Returns `(dof_a, dof_b)` where:
|
||||
/// - `dof_a = (focus_distance, aperture, max_blur, near)`
|
||||
/// - `dof_b = (far, inv_width, inv_height, 0.0)`
|
||||
///
|
||||
/// `near` and `far` come from the camera projection. `inv_width`/`inv_height`
|
||||
/// are the reciprocal texture dimensions.
|
||||
pub fn pack(
|
||||
&self,
|
||||
near: f32,
|
||||
far: f32,
|
||||
inv_width: f32,
|
||||
inv_height: f32,
|
||||
) -> (glam::Vec4, glam::Vec4) {
|
||||
(
|
||||
glam::Vec4::new(self.focus_distance, self.aperture, self.max_blur, near),
|
||||
glam::Vec4::new(far, inv_width, inv_height, 0.0),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
use wgpu::{
|
||||
BindGroup, BindGroupLayout, Buffer, BufferUsages, RenderPipeline, Sampler, Texture,
|
||||
TextureView,
|
||||
};
|
||||
|
||||
/// Internal DoF pipeline state. Allocated when DoF + HDR are both active.
|
||||
/// Recreated on resize.
|
||||
pub(crate) struct DoFPipeline {
|
||||
// Textures
|
||||
coc_texture: Texture,
|
||||
coc_view: TextureView,
|
||||
output_texture: Texture,
|
||||
output_view: TextureView,
|
||||
|
||||
// Samplers: non-filtering for CoC (depth), filtering for blur (color + CoC).
|
||||
coc_sampler: Sampler,
|
||||
blur_sampler: Sampler,
|
||||
|
||||
// Pipelines
|
||||
coc_pipeline: RenderPipeline,
|
||||
blur_pipeline: RenderPipeline,
|
||||
|
||||
// Uniform buffer (shared: same values for both passes, 32 bytes)
|
||||
uniform_buffer: Buffer,
|
||||
|
||||
// Bind groups
|
||||
coc_bind_group: BindGroup,
|
||||
blur_bind_group: BindGroup,
|
||||
|
||||
// Layouts (kept for resize)
|
||||
coc_layout: BindGroupLayout,
|
||||
blur_layout: BindGroupLayout,
|
||||
|
||||
// Dimensions
|
||||
width: u32,
|
||||
height: u32,
|
||||
}
|
||||
|
||||
impl DoFPipeline {
|
||||
pub fn new(
|
||||
device: &wgpu::Device,
|
||||
width: u32,
|
||||
height: u32,
|
||||
depth_view: &TextureView,
|
||||
color_view: &TextureView,
|
||||
) -> Self {
|
||||
// Non-filtering sampler for the CoC pass (depth textures require non-filtering).
|
||||
let coc_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
|
||||
label: Some("dof coc sampler (non-filtering)"),
|
||||
mag_filter: wgpu::FilterMode::Nearest,
|
||||
min_filter: wgpu::FilterMode::Nearest,
|
||||
mipmap_filter: wgpu::MipmapFilterMode::Nearest,
|
||||
address_mode_u: wgpu::AddressMode::ClampToEdge,
|
||||
address_mode_v: wgpu::AddressMode::ClampToEdge,
|
||||
address_mode_w: wgpu::AddressMode::ClampToEdge,
|
||||
..Default::default()
|
||||
});
|
||||
// Filtering sampler for the blur pass (color + CoC textures).
|
||||
let blur_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
|
||||
label: Some("dof blur sampler (filtering)"),
|
||||
mag_filter: wgpu::FilterMode::Linear,
|
||||
min_filter: wgpu::FilterMode::Linear,
|
||||
mipmap_filter: wgpu::MipmapFilterMode::Nearest,
|
||||
address_mode_u: wgpu::AddressMode::ClampToEdge,
|
||||
address_mode_v: wgpu::AddressMode::ClampToEdge,
|
||||
address_mode_w: wgpu::AddressMode::ClampToEdge,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
// CoC texture: R16Float, full-res.
|
||||
let coc_texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("dof coc"),
|
||||
size: wgpu::Extent3d { width, height, depth_or_array_layers: 1 },
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::R16Float,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
view_formats: &[],
|
||||
});
|
||||
let coc_view = coc_texture.create_view(&Default::default());
|
||||
|
||||
// Output texture: Rgba16Float, full-res.
|
||||
let output_texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("dof output"),
|
||||
size: wgpu::Extent3d { width, height, depth_or_array_layers: 1 },
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba16Float,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
view_formats: &[],
|
||||
});
|
||||
let output_view = output_texture.create_view(&Default::default());
|
||||
|
||||
// --- CoC bind group layout (3 bindings) ---
|
||||
let coc_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("dof coc bgl"),
|
||||
entries: &[
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 0,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Uniform,
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 1,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
sample_type: wgpu::TextureSampleType::Depth,
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
multisampled: false,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 2,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::NonFiltering),
|
||||
count: None,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// --- Blur bind group layout (4 bindings) ---
|
||||
let blur_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("dof blur bgl"),
|
||||
entries: &[
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 0,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Uniform,
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 1,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
sample_type: wgpu::TextureSampleType::Float { filterable: true },
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
multisampled: false,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 2,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
sample_type: wgpu::TextureSampleType::Float { filterable: true },
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
multisampled: false,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 3,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
|
||||
count: None,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Pipeline layouts.
|
||||
let coc_pl = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("dof coc pl"),
|
||||
bind_group_layouts: &[Some(&coc_layout)],
|
||||
..Default::default()
|
||||
});
|
||||
let blur_pl = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("dof blur pl"),
|
||||
bind_group_layouts: &[Some(&blur_layout)],
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
// Shader modules.
|
||||
let coc_module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("dof coc"),
|
||||
source: wgpu::ShaderSource::Wgsl(
|
||||
crate::utils::conf::DOF_COC_SHADER.into(),
|
||||
),
|
||||
});
|
||||
let blur_module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("dof blur"),
|
||||
source: wgpu::ShaderSource::Wgsl(
|
||||
crate::utils::conf::DOF_BLUR_SHADER.into(),
|
||||
),
|
||||
});
|
||||
|
||||
// CoC pipeline (output: R16Float).
|
||||
let coc_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("dof coc pipeline"),
|
||||
layout: Some(&coc_pl),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &coc_module,
|
||||
entry_point: Some("vs_main"),
|
||||
buffers: &[],
|
||||
compilation_options: Default::default(),
|
||||
},
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &coc_module,
|
||||
entry_point: Some("fs_main"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: wgpu::TextureFormat::R16Float,
|
||||
blend: Some(wgpu::BlendState::REPLACE),
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
}),
|
||||
primitive: wgpu::PrimitiveState {
|
||||
topology: wgpu::PrimitiveTopology::TriangleList,
|
||||
..Default::default()
|
||||
},
|
||||
depth_stencil: None,
|
||||
multisample: Default::default(),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
|
||||
// Blur pipeline (output: Rgba16Float).
|
||||
let blur_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("dof blur pipeline"),
|
||||
layout: Some(&blur_pl),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &blur_module,
|
||||
entry_point: Some("vs_main"),
|
||||
buffers: &[],
|
||||
compilation_options: Default::default(),
|
||||
},
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &blur_module,
|
||||
entry_point: Some("fs_main"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: wgpu::TextureFormat::Rgba16Float,
|
||||
blend: Some(wgpu::BlendState::REPLACE),
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
}),
|
||||
primitive: wgpu::PrimitiveState {
|
||||
topology: wgpu::PrimitiveTopology::TriangleList,
|
||||
..Default::default()
|
||||
},
|
||||
depth_stencil: None,
|
||||
multisample: Default::default(),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
|
||||
// Uniform buffer (32 bytes: 8 f32s).
|
||||
let uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("dof uniform"),
|
||||
size: 32,
|
||||
usage: BufferUsages::UNIFORM | BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
|
||||
// Bind groups.
|
||||
let coc_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("dof coc bg"),
|
||||
layout: &coc_layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: uniform_buffer.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: wgpu::BindingResource::TextureView(depth_view),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: wgpu::BindingResource::Sampler(&coc_sampler),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
let blur_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("dof blur bg"),
|
||||
layout: &blur_layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: uniform_buffer.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: wgpu::BindingResource::TextureView(color_view),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: wgpu::BindingResource::TextureView(&coc_view),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 3,
|
||||
resource: wgpu::BindingResource::Sampler(&blur_sampler),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
Self {
|
||||
coc_texture,
|
||||
coc_view,
|
||||
output_texture,
|
||||
output_view,
|
||||
coc_sampler,
|
||||
blur_sampler,
|
||||
coc_pipeline,
|
||||
blur_pipeline,
|
||||
uniform_buffer,
|
||||
coc_bind_group,
|
||||
blur_bind_group,
|
||||
coc_layout,
|
||||
blur_layout,
|
||||
width,
|
||||
height,
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes the DoF uniform buffer with current config values.
|
||||
pub fn update_uniform(
|
||||
&self,
|
||||
queue: &wgpu::Queue,
|
||||
config: &DoFConfig,
|
||||
near: f32,
|
||||
far: f32,
|
||||
) {
|
||||
let (a, b) = config.pack(near, far, 1.0 / self.width as f32, 1.0 / self.height as f32);
|
||||
let data: [f32; 8] = [a.x, a.y, a.z, a.w, b.x, b.y, b.z, b.w];
|
||||
queue.write_buffer(&self.uniform_buffer, 0, bytemuck::bytes_of(&data));
|
||||
}
|
||||
|
||||
/// Recreates textures and bind groups on resize.
|
||||
pub fn resize(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
width: u32,
|
||||
height: u32,
|
||||
depth_view: &TextureView,
|
||||
color_view: &TextureView,
|
||||
) {
|
||||
self.width = width;
|
||||
self.height = height;
|
||||
|
||||
let coc_texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("dof coc"),
|
||||
size: wgpu::Extent3d { width, height, depth_or_array_layers: 1 },
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::R16Float,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
view_formats: &[],
|
||||
});
|
||||
let coc_view = coc_texture.create_view(&Default::default());
|
||||
|
||||
let output_texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("dof output"),
|
||||
size: wgpu::Extent3d { width, height, depth_or_array_layers: 1 },
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba16Float,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
view_formats: &[],
|
||||
});
|
||||
let output_view = output_texture.create_view(&Default::default());
|
||||
|
||||
self.coc_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("dof coc bg"),
|
||||
layout: &self.coc_layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: self.uniform_buffer.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: wgpu::BindingResource::TextureView(depth_view),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: wgpu::BindingResource::Sampler(&self.coc_sampler),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
self.blur_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("dof blur bg"),
|
||||
layout: &self.blur_layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: self.uniform_buffer.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: wgpu::BindingResource::TextureView(color_view),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: wgpu::BindingResource::TextureView(&coc_view),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 3,
|
||||
resource: wgpu::BindingResource::Sampler(&self.blur_sampler),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
self.coc_texture = coc_texture;
|
||||
self.coc_view = coc_view;
|
||||
self.output_texture = output_texture;
|
||||
self.output_view = output_view;
|
||||
}
|
||||
|
||||
/// Returns the DoF output texture (for re-pointing the TM bind group).
|
||||
pub fn output_texture(&self) -> &Texture {
|
||||
&self.output_texture
|
||||
}
|
||||
|
||||
/// Returns the DoF output view.
|
||||
pub fn output_view(&self) -> &TextureView {
|
||||
&self.output_view
|
||||
}
|
||||
|
||||
pub fn coc_view(&self) -> &TextureView {
|
||||
&self.coc_view
|
||||
}
|
||||
|
||||
pub fn coc_pipeline(&self) -> &RenderPipeline {
|
||||
&self.coc_pipeline
|
||||
}
|
||||
|
||||
pub fn blur_pipeline(&self) -> &RenderPipeline {
|
||||
&self.blur_pipeline
|
||||
}
|
||||
|
||||
pub fn coc_bind_group(&self) -> &BindGroup {
|
||||
&self.coc_bind_group
|
||||
}
|
||||
|
||||
pub fn blur_bind_group(&self) -> &BindGroup {
|
||||
&self.blur_bind_group
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn config_new_clamps_aperture() {
|
||||
let c = DoFConfig::new(5.0, 2.0, 8.0);
|
||||
assert_eq!(c.aperture, 1.0);
|
||||
assert_eq!(c.focus_distance, 5.0);
|
||||
assert_eq!(c.max_blur, 8.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_new_clamps_negative_aperture() {
|
||||
let c = DoFConfig::new(5.0, -1.0, 8.0);
|
||||
assert_eq!(c.aperture, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cinematic_preset() {
|
||||
let c = DoFConfig::cinematic(5.0);
|
||||
assert_eq!(c.focus_distance, 5.0);
|
||||
assert!((c.aperture - 0.3).abs() < f32::EPSILON);
|
||||
assert!((c.max_blur - 12.0).abs() < f32::EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subtle_preset() {
|
||||
let c = DoFConfig::subtle(3.0);
|
||||
assert_eq!(c.focus_distance, 3.0);
|
||||
assert!((c.aperture - 0.1).abs() < f32::EPSILON);
|
||||
assert!((c.max_blur - 8.0).abs() < f32::EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pack_layout() {
|
||||
let c = DoFConfig::new(5.0, 0.5, 8.0);
|
||||
let (a, b) = c.pack(0.1, 100.0, 1.0 / 1920.0, 1.0 / 1080.0);
|
||||
assert!((a.x - 5.0).abs() < f32::EPSILON);
|
||||
assert!((a.y - 0.5).abs() < f32::EPSILON);
|
||||
assert!((a.z - 8.0).abs() < f32::EPSILON);
|
||||
assert!((a.w - 0.1).abs() < f32::EPSILON);
|
||||
assert!((b.x - 100.0).abs() < f32::EPSILON);
|
||||
assert!((b.y - 1.0 / 1920.0).abs() < f32::EPSILON);
|
||||
assert!((b.z - 1.0 / 1080.0).abs() < f32::EPSILON);
|
||||
assert_eq!(b.w, 0.0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
//! # Fog Module (Étape 25)
|
||||
//!
|
||||
//! Distance fog: fades objects into a background color based on their distance
|
||||
//! from the camera. Primary use case: masking the edge of the rendered world
|
||||
//! to create the illusion of an infinite scene.
|
||||
//!
|
||||
//! Three modes are supported:
|
||||
//! - **Linear**: hard cutoff between `near` and `far` distances
|
||||
//! - **Exponential**: gradual falloff `exp(-density * d)`
|
||||
//! - **Exponential²**: sharper cutoff `exp(-density² * d²)` — best for masking
|
||||
//!
|
||||
//! Zero cost when disabled: `fog_enabled = 0` → the shader branch is never taken.
|
||||
|
||||
/// Fog attenuation mode.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
pub enum FogMode {
|
||||
/// Linear fade between `near` and `far` distances.
|
||||
Linear,
|
||||
/// Exponential falloff: `exp(-density * distance)`.
|
||||
#[default]
|
||||
Exponential,
|
||||
/// Exponential squared: `exp(-density² * distance²)`. Sharper cutoff.
|
||||
Exponential2,
|
||||
}
|
||||
|
||||
impl FogMode {
|
||||
/// Numeric value written to the GPU uniform (0 = linear, 1 = exp, 2 = exp²).
|
||||
pub fn as_f32(self) -> f32 {
|
||||
match self {
|
||||
FogMode::Linear => 0.0,
|
||||
FogMode::Exponential => 1.0,
|
||||
FogMode::Exponential2 => 2.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fog configuration for the scene.
|
||||
///
|
||||
/// When not set (no `.with_fog()` call), the renderer writes `fog_enabled = 0`
|
||||
/// and the shader skips the fog block entirely — zero GPU cost.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct FogConfig {
|
||||
/// Attenuation mode (linear / exp / exp²).
|
||||
pub mode: FogMode,
|
||||
/// Fog color (RGB, linear space). Should match the sky/clear color for
|
||||
/// a seamless "infinite world" illusion.
|
||||
pub color: [f32; 3],
|
||||
/// Near distance (Linear mode only). Fog starts at this distance.
|
||||
pub near: f32,
|
||||
/// Far distance (Linear mode only). Fully fogged at this distance.
|
||||
pub far: f32,
|
||||
/// Density (Exponential / Exponential² modes). Higher = thicker fog.
|
||||
/// Typical range: 0.01 (very thin) to 0.3 (very dense).
|
||||
pub density: f32,
|
||||
}
|
||||
|
||||
impl FogConfig {
|
||||
/// Linear fog: fades from `near` to `far` distance.
|
||||
pub fn linear(color: [f32; 3], near: f32, far: f32) -> Self {
|
||||
Self {
|
||||
mode: FogMode::Linear,
|
||||
color,
|
||||
near,
|
||||
far,
|
||||
density: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Exponential fog: `factor = exp(-density * distance)`.
|
||||
/// Natural-looking fog (forest, lake, atmosphere).
|
||||
pub fn exponential(color: [f32; 3], density: f32) -> Self {
|
||||
Self {
|
||||
mode: FogMode::Exponential,
|
||||
color,
|
||||
near: 0.0,
|
||||
far: 0.0,
|
||||
density,
|
||||
}
|
||||
}
|
||||
|
||||
/// Exponential² fog: `factor = exp(-density² * distance²)`.
|
||||
/// Gradual start, sharp cutoff — ideal for masking world edges.
|
||||
pub fn exponential2(color: [f32; 3], density: f32) -> Self {
|
||||
Self {
|
||||
mode: FogMode::Exponential2,
|
||||
color,
|
||||
near: 0.0,
|
||||
far: 0.0,
|
||||
density,
|
||||
}
|
||||
}
|
||||
|
||||
/// Pack into two `Vec4`s for the GPU uniform buffer.
|
||||
/// - `a` = (enabled, mode, near, far)
|
||||
/// - `b` = (density, color_r, color_g, color_b)
|
||||
pub fn pack(&self, enabled: bool) -> (glam::Vec4, glam::Vec4) {
|
||||
(
|
||||
glam::Vec4::new(
|
||||
if enabled { 1.0 } else { 0.0 },
|
||||
self.mode.as_f32(),
|
||||
self.near,
|
||||
self.far,
|
||||
),
|
||||
glam::Vec4::new(
|
||||
self.density,
|
||||
self.color[0],
|
||||
self.color[1],
|
||||
self.color[2],
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn mode_as_f32() {
|
||||
assert_eq!(FogMode::Linear.as_f32(), 0.0);
|
||||
assert_eq!(FogMode::Exponential.as_f32(), 1.0);
|
||||
assert_eq!(FogMode::Exponential2.as_f32(), 2.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linear_pack() {
|
||||
let cfg = FogConfig::linear([0.7, 0.8, 0.9], 5.0, 50.0);
|
||||
let (a, b) = cfg.pack(true);
|
||||
assert_eq!(a, glam::Vec4::new(1.0, 0.0, 5.0, 50.0));
|
||||
assert_eq!(b, glam::Vec4::new(0.0, 0.7, 0.8, 0.9));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exp2_pack_disabled() {
|
||||
let cfg = FogConfig::exponential2([1.0, 1.0, 1.0], 0.1);
|
||||
let (a, b) = cfg.pack(false);
|
||||
assert_eq!(a.x, 0.0); // disabled
|
||||
assert_eq!(a.y, 2.0); // exp² mode
|
||||
assert_eq!(b.x, 0.1); // density
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,8 @@
|
||||
|
||||
pub mod bloom;
|
||||
pub mod context;
|
||||
pub mod dof;
|
||||
pub mod fog;
|
||||
pub mod frame;
|
||||
pub mod frustum;
|
||||
pub mod geometry;
|
||||
@@ -24,6 +26,8 @@ pub mod transform;
|
||||
// Re-exports
|
||||
pub use bloom::BloomConfig;
|
||||
pub use context::Context;
|
||||
pub use dof::DoFConfig;
|
||||
pub use fog::{FogConfig, FogMode};
|
||||
pub use frame::Frame;
|
||||
pub use frustum::Frustum;
|
||||
pub use geometry::{BBox, Geometry, GeometryError};
|
||||
|
||||
+134
-6
@@ -170,6 +170,12 @@ pub struct Renderer {
|
||||
msaa_depth_texture: Option<wgpu::Texture>,
|
||||
/// MSAA depth view used as the main pass depth attachment when MSAA is active.
|
||||
msaa_depth_view: Option<wgpu::TextureView>,
|
||||
/// Fog configuration (Étape 25). `None` = fog disabled (zero overhead).
|
||||
fog: Option<super::fog::FogConfig>,
|
||||
/// DoF configuration (Étape 26). `None` = DoF disabled (zero overhead).
|
||||
dof: Option<super::dof::DoFConfig>,
|
||||
/// DoF pipeline (Étape 26). Present only when DoF + HDR are both active.
|
||||
dof_pipeline: Option<super::dof::DoFPipeline>,
|
||||
}
|
||||
|
||||
/// Internal HDR pipeline state: offscreen `Rgba16Float` texture + tone mapping render pipeline.
|
||||
@@ -213,6 +219,8 @@ impl Renderer {
|
||||
hdr: Option<ToneMapper>,
|
||||
bloom_config: Option<BloomConfig>,
|
||||
msaa_config: Option<MsaaConfig>,
|
||||
fog: Option<super::fog::FogConfig>,
|
||||
dof_config: Option<super::dof::DoFConfig>,
|
||||
) -> Self {
|
||||
let queue: wgpu::Queue = context.queue.clone();
|
||||
let device: wgpu::Device = context.device.clone();
|
||||
@@ -220,7 +228,7 @@ impl Renderer {
|
||||
|
||||
// Step 9 (DRAFT 9.1): depth texture + view, allocated once at the initial surface
|
||||
// size (D3). The isolated helper keeps the Phase 4.4 recreate trivial.
|
||||
let (depth_texture, depth_view) = create_depth_texture(&device, width, height);
|
||||
let (depth_texture, depth_view) = create_depth_texture(&device, width, height, dof_config.is_some());
|
||||
|
||||
// Shared frame uniforms: identity camera + white directional light, lit mode by default.
|
||||
// Values become meaningful once an active camera is wired (Step 4.3); for now the default
|
||||
@@ -606,11 +614,16 @@ impl Renderer {
|
||||
hdr: None,
|
||||
bloom: None,
|
||||
bloom_config: bloom_config.clone().unwrap_or_default(),
|
||||
msaa_config: msaa_config.clone().unwrap_or_default(),
|
||||
// When MSAA is not requested (None), store sample_count=1 (disabled).
|
||||
// Using `Default` here would give 4 and incorrectly trigger MSAA allocation.
|
||||
msaa_config: msaa_config.unwrap_or(MsaaConfig { sample_count: 1 }),
|
||||
msaa_color_texture: None,
|
||||
msaa_color_view: None,
|
||||
msaa_depth_texture: None,
|
||||
msaa_depth_view: None,
|
||||
fog,
|
||||
dof: dof_config,
|
||||
dof_pipeline: None,
|
||||
};
|
||||
// Seed the shared frame buffer with an identity camera + current unlit flag so the low-level
|
||||
// `render` path (which has no window/camera) sees coherent values before `render_scene` runs.
|
||||
@@ -657,6 +670,30 @@ impl Renderer {
|
||||
renderer.msaa_depth_texture = Some(msaa_depth_tex);
|
||||
renderer.msaa_depth_view = Some(msaa_depth_view);
|
||||
}
|
||||
// Étape 26: allocate the DoF pipeline when DoF + HDR are both active.
|
||||
if renderer.dof.is_some() {
|
||||
if let Some(hdr) = &mut renderer.hdr {
|
||||
// The color source for DoF is the HDR texture (or bloom composite if bloom is active).
|
||||
let color_tex: &wgpu::Texture = if let Some(bloom) = &renderer.bloom {
|
||||
bloom.composite_texture()
|
||||
} else {
|
||||
&hdr.texture
|
||||
};
|
||||
let color_view = color_tex.create_view(&Default::default());
|
||||
let dof_pipe = super::dof::DoFPipeline::new(
|
||||
&renderer.device, width, height, &renderer.depth_view, &color_view,
|
||||
);
|
||||
// Recreate the TM bind group to read from the DoF output texture.
|
||||
let (bg, _buf) = create_hdr_bind_group(
|
||||
&renderer.device, &hdr.layout, &hdr.sampler, dof_pipe.output_texture(), width, height,
|
||||
);
|
||||
hdr.bind_group = bg;
|
||||
renderer.dof_pipeline = Some(dof_pipe);
|
||||
} else {
|
||||
eprintln!("[WSG] DoF requires HDR: call with_hdr() before with_dof(). DoF disabled.");
|
||||
renderer.dof = None;
|
||||
}
|
||||
}
|
||||
renderer
|
||||
}
|
||||
|
||||
@@ -686,12 +723,26 @@ impl Renderer {
|
||||
self.write_default_frame_uniforms();
|
||||
}
|
||||
|
||||
/// Sets the fog configuration at runtime (Étape 25). `None` disables fog.
|
||||
/// Takes effect on the next `render_scene` call.
|
||||
pub fn set_fog(&mut self, fog: Option<super::fog::FogConfig>) {
|
||||
self.fog = fog;
|
||||
}
|
||||
|
||||
/// Sets the DoF configuration at runtime (Étape 26). `None` disables DoF.
|
||||
/// Only effective when DoF was enabled at construction (pipeline already allocated).
|
||||
pub fn set_dof(&mut self, config: Option<super::dof::DoFConfig>) {
|
||||
if self.dof_pipeline.is_some() {
|
||||
self.dof = config;
|
||||
}
|
||||
}
|
||||
|
||||
/// Recreates the depth texture at a new size, used on window resize (ROADMAP Phase 4.4).
|
||||
/// The previous depth texture is dropped when its field is replaced — no leak, no double
|
||||
/// allocation. The helper `create_depth_texture` (Step 9, D3) is reused so the recreate stays
|
||||
/// trivial. Inputs: width/height — the new surface dimensions in pixels.
|
||||
pub fn resize_depth(&mut self, width: u32, height: u32) {
|
||||
let (depth_texture, depth_view) = create_depth_texture(&self.device, width, height);
|
||||
let (depth_texture, depth_view) = create_depth_texture(&self.device, width, height, self.dof_pipeline.is_some());
|
||||
self._depth_texture = depth_texture;
|
||||
self.depth_view = depth_view;
|
||||
// Step 19 (D9): refresh the viewport height — the unit of the LOD projected-size test.
|
||||
@@ -744,6 +795,23 @@ impl Renderer {
|
||||
self.msaa_depth_texture = Some(msaa_depth_tex);
|
||||
self.msaa_depth_view = Some(msaa_depth_view);
|
||||
}
|
||||
// Étape 26: resize DoF textures + re-point TM bind group at the DoF output.
|
||||
if self.dof_pipeline.is_some() {
|
||||
if let Some(hdr) = &mut self.hdr {
|
||||
let color_tex: &wgpu::Texture = if let Some(bloom) = &self.bloom {
|
||||
bloom.composite_texture()
|
||||
} else {
|
||||
&hdr.texture
|
||||
};
|
||||
let color_view = color_tex.create_view(&Default::default());
|
||||
let dof_pipe = self.dof_pipeline.as_mut().unwrap();
|
||||
dof_pipe.resize(&self.device, width, height, &self.depth_view, &color_view);
|
||||
let (bg, _buf) = create_hdr_bind_group(
|
||||
&self.device, &hdr.layout, &hdr.sampler, dof_pipe.output_texture(), width, height,
|
||||
);
|
||||
hdr.bind_group = bg;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Updates the stored surface texture format after a surface reconfigure (ROADMAP Phase 4.4).
|
||||
@@ -801,6 +869,9 @@ impl Renderer {
|
||||
light_view_proj,
|
||||
shadow_params,
|
||||
options: [if self.unlit { 1 } else { 0 }, shadow_on, 0, 0],
|
||||
// Étape 25: fog params (disabled by default → fog_a.x = 0).
|
||||
fog_a: self.fog.as_ref().map(|f| f.pack(true).0).unwrap_or(glam::Vec4::ZERO),
|
||||
fog_b: self.fog.as_ref().map(|f| f.pack(true).1).unwrap_or(glam::Vec4::ZERO),
|
||||
};
|
||||
self.queue
|
||||
.write_buffer(&self.frame_buffer, 0, bytemuck::bytes_of(&frame));
|
||||
@@ -1168,9 +1239,61 @@ impl Renderer {
|
||||
bloom.record_passes(&mut encoder, &self.queue, &self.bloom_config);
|
||||
}
|
||||
|
||||
// 8d. Étape 26: DoF passes (CoC → Blur).
|
||||
// Only runs when DoF + HDR are active and DoF config is set.
|
||||
// The DoF output texture becomes the input to the TM pass.
|
||||
if let Some(dof_pipe) = &self.dof_pipeline {
|
||||
if let Some(dof_cfg) = &self.dof {
|
||||
// Update the shared uniform buffer.
|
||||
dof_pipe.update_uniform(&self.queue, dof_cfg, 0.1, 100.0);
|
||||
|
||||
// Pass 1: CoC (depth → R16Float radius texture).
|
||||
{
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("dof coc pass"),
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: dof_pipe.coc_view(),
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})],
|
||||
depth_stencil_attachment: None,
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(dof_pipe.coc_pipeline());
|
||||
pass.set_bind_group(0, dof_pipe.coc_bind_group(), &[]);
|
||||
pass.draw(0..3, 0..1);
|
||||
}
|
||||
|
||||
// Pass 2: Blur (color + CoC → blurred Rgba16Float output).
|
||||
{
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("dof blur pass"),
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: dof_pipe.output_view(),
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})],
|
||||
depth_stencil_attachment: None,
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(dof_pipe.blur_pipeline());
|
||||
pass.set_bind_group(0, dof_pipe.blur_bind_group(), &[]);
|
||||
pass.draw(0..3, 0..1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 9. Étape 20: tone mapping pass — renders a fullscreen triangle that reads the HDR
|
||||
// texture (or the bloom composite when bloom is active), applies exposure + tone
|
||||
// mapping curve, and writes to the surface.
|
||||
// texture (or the bloom composite when bloom is active, or DoF output when DoF is active),
|
||||
// applies exposure + tone mapping curve, and writes to the surface.
|
||||
if let Some(hdr) = &self.hdr {
|
||||
let mut tm_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("tone mapping pass"),
|
||||
@@ -1577,7 +1700,12 @@ fn create_depth_texture(
|
||||
device: &wgpu::Device,
|
||||
width: u32,
|
||||
height: u32,
|
||||
texturable: bool,
|
||||
) -> (wgpu::Texture, wgpu::TextureView) {
|
||||
let mut usage = wgpu::TextureUsages::RENDER_ATTACHMENT;
|
||||
if texturable {
|
||||
usage |= wgpu::TextureUsages::TEXTURE_BINDING;
|
||||
}
|
||||
let depth_texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("depth texture"),
|
||||
size: wgpu::Extent3d {
|
||||
@@ -1589,7 +1717,7 @@ fn create_depth_texture(
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: DEPTH_FORMAT,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
|
||||
usage,
|
||||
view_formats: &[],
|
||||
});
|
||||
let depth_view = depth_texture.create_view(&wgpu::TextureViewDescriptor::default());
|
||||
|
||||
@@ -61,6 +61,7 @@ pub use crate::core::ToneMapper;
|
||||
/// Re-export of the MSAA configuration for convenient top-level access.
|
||||
/// Users enable MSAA via `AppBuilder::with_msaa(4)`.
|
||||
pub use crate::core::MsaaConfig;
|
||||
pub use crate::core::{DoFConfig, FogConfig, FogMode};
|
||||
|
||||
/// Re-export of the geometry data type (positions, normals, UVs, indices).
|
||||
pub use crate::core::Geometry;
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@
|
||||
// Core types
|
||||
pub use crate::core::geometry::{BBox, Geometry};
|
||||
pub use crate::core::transform::Transform;
|
||||
pub use crate::core::{BloomConfig, MsaaConfig, ShadowConfig, ToneMapper};
|
||||
pub use crate::core::{BloomConfig, DoFConfig, FogConfig, FogMode, MsaaConfig, ShadowConfig, ToneMapper};
|
||||
pub use crate::resources::Material;
|
||||
|
||||
// Camera
|
||||
|
||||
@@ -100,6 +100,11 @@ pub struct FrameUniforms {
|
||||
/// `options[1]` = shadows enabled (1 → sample the shadow map, checked alongside
|
||||
/// `shadow_light_index`). Offset 256 + 64·MAX_LIGHTS.
|
||||
pub options: [u32; 4],
|
||||
/// Fog params A (Étape 25): x = enabled (0/1), y = mode (0=linear, 1=exp, 2=exp²),
|
||||
/// z = near (linear), w = far (linear).
|
||||
pub fog_a: Vec4,
|
||||
/// Fog params B (Étape 25): x = density (exp/exp²), y/z/w = fog color RGB.
|
||||
pub fog_b: Vec4,
|
||||
}
|
||||
|
||||
impl Default for FrameUniforms {
|
||||
@@ -126,6 +131,9 @@ impl Default for FrameUniforms {
|
||||
light_view_proj: Mat4::IDENTITY,
|
||||
shadow_params: Vec4::ZERO,
|
||||
options: [0, 0, 0, 0],
|
||||
// Fog disabled by default (Étape 25): enabled=0 → shader branch skipped.
|
||||
fog_a: Vec4::ZERO,
|
||||
fog_b: Vec4::ZERO,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -443,9 +451,10 @@ mod tests {
|
||||
// The offsets below must match the offset table in standard_shader.wgsl.
|
||||
// Header (view..ambient) = 160, lights = 64·MAX_LIGHTS, then counters (4×u32 = 16),
|
||||
// light_view_proj (64) + shadow_params (16) + options (16) = 112 after the counters.
|
||||
// Total = 160 + 64·8 + 16 + 112 = 784 bytes.
|
||||
assert_eq!(size_of::<FrameUniforms>(), 784);
|
||||
assert_eq!(size_of::<FrameUniforms>(), 160 + 512 + 112);
|
||||
// Fog (Étape 25): fog_a (16) + fog_b (16) = 32 bytes.
|
||||
// Total = 160 + 64·8 + 16 + 112 + 32 = 816 bytes.
|
||||
assert_eq!(size_of::<FrameUniforms>(), 816);
|
||||
assert_eq!(size_of::<FrameUniforms>(), 160 + 512 + 112 + 32);
|
||||
assert_eq!(align_of::<FrameUniforms>(), 16);
|
||||
|
||||
let f = FrameUniforms::default();
|
||||
@@ -482,13 +491,24 @@ mod tests {
|
||||
offset_of!(FrameUniforms, options),
|
||||
160 + 64 * MAX_LIGHTS + 96
|
||||
);
|
||||
// Étape 25: fog fields at the end (two vec4 = 32 bytes).
|
||||
assert_eq!(
|
||||
offset_of!(FrameUniforms, fog_a),
|
||||
160 + 64 * MAX_LIGHTS + 112
|
||||
);
|
||||
assert_eq!(
|
||||
offset_of!(FrameUniforms, fog_b),
|
||||
160 + 64 * MAX_LIGHTS + 128
|
||||
);
|
||||
// Default is lit mode (unlit flag cleared), one directional light, no point/spot lights,
|
||||
// shadows off (sentinel = MAX_LIGHTS).
|
||||
// shadows off (sentinel = MAX_LIGHTS), fog disabled (all zeros).
|
||||
assert_eq!(f.options[0], 0);
|
||||
assert_eq!(f.num_directional, 1);
|
||||
assert_eq!(f.num_point, 0);
|
||||
assert_eq!(f.num_spot, 0);
|
||||
assert_eq!(f.shadow_light_index, MAX_LIGHTS as u32);
|
||||
assert_eq!(f.fog_a, glam::Vec4::ZERO);
|
||||
assert_eq!(f.fog_b, glam::Vec4::ZERO);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
// DoF Blur pass (Étape 26)
|
||||
// Reads the HDR color texture + CoC texture, applies a 12-tap disc blur with
|
||||
// per-pixel variable radius (from CoC), and writes the blurred result.
|
||||
// Output: Rgba16Float texture (full-res, HDR).
|
||||
|
||||
struct DoFUniform {
|
||||
focus_distance: f32,
|
||||
aperture: f32,
|
||||
max_blur: f32,
|
||||
near: f32,
|
||||
far: f32,
|
||||
inv_width: f32,
|
||||
inv_height: f32,
|
||||
pad: f32,
|
||||
};
|
||||
|
||||
@group(0) @binding(0) var<uniform> u: DoFUniform;
|
||||
@group(0) @binding(1) var color_tex: texture_2d<f32>;
|
||||
@group(0) @binding(2) var coc_tex: texture_2d<f32>;
|
||||
@group(0) @binding(3) var s: sampler;
|
||||
|
||||
// 12-tap disc pattern (Poisson-disc-like) for natural bokeh.
|
||||
const TAPS: array<vec2<f32>, 12> = array<vec2<f32>, 12>(
|
||||
vec2( 0.000, 0.000), // center
|
||||
vec2( 0.000, 1.000), // top
|
||||
vec2( 1.000, 0.000), // right
|
||||
vec2( 0.000, -1.000), // bottom
|
||||
vec2(-1.000, 0.000), // left
|
||||
vec2( 0.707, 0.707), // top-right diagonal
|
||||
vec2( 0.707, -0.707), // bottom-right diagonal
|
||||
vec2(-0.707, 0.707), // top-left diagonal
|
||||
vec2(-0.707, -0.707), // bottom-left diagonal
|
||||
vec2( 0.383, 0.924), // upper ring
|
||||
vec2(-0.383, 0.924), // upper ring
|
||||
vec2( 0.383, -0.924), // lower ring
|
||||
);
|
||||
|
||||
// Fullscreen triangle (same as TM): top-left origin.
|
||||
@vertex
|
||||
fn vs_main(@builtin(vertex_index) vid: u32) -> @builtin(position) vec4<f32> {
|
||||
switch vid {
|
||||
case 0u {
|
||||
return vec4<f32>(-1.0, -1.0, 0.0, 1.0);
|
||||
}
|
||||
case 1u {
|
||||
return vec4<f32>(3.0, -1.0, 0.0, 1.0);
|
||||
}
|
||||
default {
|
||||
return vec4<f32>(-1.0, 3.0, 0.0, 1.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fs_main(@builtin(position) frag_pos: vec4<f32>) -> @location(0) vec4<f32> {
|
||||
// UV from fragment pixel position (same pattern as TM shader).
|
||||
let uv = frag_pos.xy * vec2(u.inv_width, u.inv_height);
|
||||
|
||||
let coc = textureSample(coc_tex, s, uv).r;
|
||||
|
||||
// Below 0.5px: no visible blur, skip for performance.
|
||||
if (coc < 0.5) {
|
||||
return textureSample(color_tex, s, uv);
|
||||
}
|
||||
|
||||
// Variable-radius disc blur.
|
||||
let texel = vec2(u.inv_width, u.inv_height);
|
||||
var sum = vec4<f32>(0.0);
|
||||
for (var i = 0u; i < 12u; i++) {
|
||||
let offset = TAPS[i] * coc * texel;
|
||||
sum += textureSample(color_tex, s, uv + offset);
|
||||
}
|
||||
return sum / 12.0;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
// DoF Circle-of-Confusion pass (Étape 26)
|
||||
// Reads the scene depth buffer, linearizes it to world distance, and computes
|
||||
// a per-pixel blur radius (in pixels) based on the DoF parameters.
|
||||
// Output: R16Float texture (single channel = CoC radius in pixels).
|
||||
|
||||
struct DoFUniform {
|
||||
focus_distance: f32,
|
||||
aperture: f32,
|
||||
max_blur: f32,
|
||||
near: f32,
|
||||
far: f32,
|
||||
inv_width: f32,
|
||||
inv_height: f32,
|
||||
pad: f32,
|
||||
};
|
||||
|
||||
@group(0) @binding(0) var<uniform> u: DoFUniform;
|
||||
@group(0) @binding(1) var depth_tex: texture_depth_2d;
|
||||
@group(0) @binding(2) var s: sampler;
|
||||
|
||||
// Fullscreen triangle (same as TM): top-left origin.
|
||||
@vertex
|
||||
fn vs_main(@builtin(vertex_index) vid: u32) -> @builtin(position) vec4<f32> {
|
||||
switch vid {
|
||||
case 0u {
|
||||
return vec4<f32>(-1.0, -1.0, 0.0, 1.0);
|
||||
}
|
||||
case 1u {
|
||||
return vec4<f32>(3.0, -1.0, 0.0, 1.0);
|
||||
}
|
||||
default {
|
||||
return vec4<f32>(-1.0, 3.0, 0.0, 1.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fs_main(@builtin(position) frag_pos: vec4<f32>) -> @location(0) f32 {
|
||||
// UV from fragment pixel position (same pattern as TM shader).
|
||||
let uv = frag_pos.xy * vec2(u.inv_width, u.inv_height);
|
||||
|
||||
let ndc_z = textureSample(depth_tex, s, uv);
|
||||
|
||||
// Linearize: NDC depth [0,1] → world distance (perspective projection)
|
||||
let dist = u.near * u.far / (u.far - ndc_z * (u.far - u.near));
|
||||
|
||||
// CoC in pixels: proportional to |dist - focus_distance|
|
||||
var coc = u.max_blur * u.aperture * abs(dist - u.focus_distance)
|
||||
/ max(u.focus_distance, 1e-4);
|
||||
coc = min(coc, u.max_blur);
|
||||
|
||||
// Far plane (depth ≈ 1.0) → no blur (sky/background)
|
||||
if (ndc_z >= 0.9999) {
|
||||
coc = 0.0;
|
||||
}
|
||||
|
||||
return coc;
|
||||
}
|
||||
@@ -8,7 +8,7 @@
|
||||
//!
|
||||
//! ## Uniform Contract
|
||||
//! Four bind groups, shared by every material (one single pipeline layout — voir Étape 3) :
|
||||
//! - `@group(0) @binding(0)` : `FrameUniforms` (per-frame, camera + lights + shadow) [784 bytes]
|
||||
//! - `@group(0) @binding(0)` : `FrameUniforms` (per-frame, camera + lights + shadow + fog) [816 bytes]
|
||||
//! - `@group(1) @binding(0)` : `ObjectUniform` (per-entity model matrix) [64 bytes]
|
||||
//! - `@group(2) @binding(0)` : `texture_sampler` (sampler) — diffuse (Étape 10)
|
||||
//! - `@group(2) @binding(1)` : `diffuse_texture` (texture_2d<f32>) (Étape 10)
|
||||
@@ -93,6 +93,8 @@ struct FrameUniforms {
|
||||
light_view_proj: mat4x4<f32>, // world → shadow light clip space (Étape 14, D3)
|
||||
shadow_params: vec4<f32>, // .x = map size, .y = constant bias, .z = slope bias
|
||||
options: vec4<u32>, // .x = unlit flag ; .y = shadows on
|
||||
fog_a: vec4<f32>, // .x=enabled .y=mode .z=near .w=far (Étape 25)
|
||||
fog_b: vec4<f32>, // .x=density .y/.z/.w=fog color RGB (Étape 25)
|
||||
};
|
||||
|
||||
struct ObjectUniform {
|
||||
@@ -151,7 +153,8 @@ fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
|
||||
// Flat (unlit) mode : pas d'éclairage, texel * couleur du vertex + emissive.
|
||||
if (frame.options.x != 0u) {
|
||||
let emissive_contrib = base * object.emissive.rgb * object.emissive.a;
|
||||
return vec4<f32>(base + emissive_contrib, in.color.a);
|
||||
let final_rgb = base + emissive_contrib;
|
||||
return vec4<f32>(apply_fog(final_rgb, in.world_pos), in.color.a);
|
||||
}
|
||||
|
||||
let n = normalize(in.normal);
|
||||
@@ -208,7 +211,32 @@ fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
|
||||
// Étape 22 (6.2): emissive — added to the lit result (independent of lights/shadows).
|
||||
// Zero emissive (default) → no change (non-regression). In HDR, intensity > 1.0 glows.
|
||||
let emissive_contrib = base * object.emissive.rgb * object.emissive.a;
|
||||
return vec4<f32>(lit + emissive_contrib, in.color.a);
|
||||
let final_rgb = lit + emissive_contrib;
|
||||
return vec4<f32>(apply_fog(final_rgb, in.world_pos), in.color.a);
|
||||
}
|
||||
|
||||
// Étape 25 : distance fog. Blends the final color toward the fog color based on the
|
||||
// fragment's distance from the camera. Three modes: linear, exponential, exponential².
|
||||
// When `fog_a.x == 0` (disabled), returns the input color unchanged — zero cost.
|
||||
fn apply_fog(color: vec3<f32>, world_pos: vec3<f32>) -> vec3<f32> {
|
||||
if (frame.fog_a.x < 0.5) {
|
||||
return color;
|
||||
}
|
||||
let dist = length(world_pos - frame.cam_pos.xyz);
|
||||
var fog_factor: f32;
|
||||
if (frame.fog_a.y < 0.5) {
|
||||
// Linear: 1.0 at near, 0.0 at far.
|
||||
fog_factor = saturate((frame.fog_a.w - dist) / max(frame.fog_a.w - frame.fog_a.z, 1e-4));
|
||||
} else if (frame.fog_a.y < 1.5) {
|
||||
// Exponential: exp(-density * distance).
|
||||
fog_factor = exp(-frame.fog_b.x * dist);
|
||||
} else {
|
||||
// Exponential²: exp(-density² * distance²) — sharper cutoff.
|
||||
let d2 = frame.fog_b.x * frame.fog_b.x;
|
||||
fog_factor = exp(-d2 * dist * dist);
|
||||
}
|
||||
let fog_color = frame.fog_b.yzw;
|
||||
return mix(color, fog_color, 1.0 - fog_factor);
|
||||
}
|
||||
|
||||
// Étape 14 (DRAFT 3.2, D5) : PCF shadow factor for this fragment. Reprojects the world position
|
||||
|
||||
@@ -58,6 +58,12 @@ pub const BLOOM_BLUR_SHADER: &str = include_str!("../shaders/bloom_blur.wgsl");
|
||||
/// to the full-res HDR texture, scaled by intensity. Writes to a full-res composite texture.
|
||||
pub const BLOOM_COMPOSITE_SHADER: &str = include_str!("../shaders/bloom_composite.wgsl");
|
||||
|
||||
/// DoF circle-of-confusion shader (Étape 26).
|
||||
pub const DOF_COC_SHADER: &str = include_str!("../shaders/dof_coc.wgsl");
|
||||
|
||||
/// DoF blur shader (Étape 26).
|
||||
pub const DOF_BLUR_SHADER: &str = include_str!("../shaders/dof_blur.wgsl");
|
||||
|
||||
/// Fixed capacity of the GPU-driven entity slot buffers (Phase 3). The transform, matrix, bbox and
|
||||
/// indirect-draw-args buffers are all sized to this capacity and allocated once; per frame the CPU
|
||||
/// rewrites only the transform slots and the cull uniforms.
|
||||
|
||||
@@ -182,3 +182,47 @@ fn bloom_composite_shader_is_valid_wgsl() {
|
||||
entry_names.sort();
|
||||
assert_eq!(entry_names, vec!["fs_main", "vs_main"]);
|
||||
}
|
||||
|
||||
/// Parses and fully validates the `dof_coc.wgsl` shader (Étape 26) via naga.
|
||||
#[test]
|
||||
fn dof_coc_shader_is_valid_wgsl() {
|
||||
let src = include_str!("../src/shaders/dof_coc.wgsl");
|
||||
let module = naga::front::wgsl::parse_str(src)
|
||||
.unwrap_or_else(|e| panic!("dof_coc.wgsl: parsing error: {e:?}"));
|
||||
let mut validator = naga::valid::Validator::new(
|
||||
naga::valid::ValidationFlags::all(),
|
||||
naga::valid::Capabilities::all(),
|
||||
);
|
||||
validator
|
||||
.validate(&module)
|
||||
.unwrap_or_else(|e| panic!("dof_coc.wgsl: validation failed: {e:?}"));
|
||||
let mut entry_names: Vec<&str> = module
|
||||
.entry_points
|
||||
.iter()
|
||||
.map(|ep| ep.name.as_str())
|
||||
.collect();
|
||||
entry_names.sort();
|
||||
assert_eq!(entry_names, vec!["fs_main", "vs_main"]);
|
||||
}
|
||||
|
||||
/// Parses and fully validates the `dof_blur.wgsl` shader (Étape 26) via naga.
|
||||
#[test]
|
||||
fn dof_blur_shader_is_valid_wgsl() {
|
||||
let src = include_str!("../src/shaders/dof_blur.wgsl");
|
||||
let module = naga::front::wgsl::parse_str(src)
|
||||
.unwrap_or_else(|e| panic!("dof_blur.wgsl: parsing error: {e:?}"));
|
||||
let mut validator = naga::valid::Validator::new(
|
||||
naga::valid::ValidationFlags::all(),
|
||||
naga::valid::Capabilities::all(),
|
||||
);
|
||||
validator
|
||||
.validate(&module)
|
||||
.unwrap_or_else(|e| panic!("dof_blur.wgsl: validation failed: {e:?}"));
|
||||
let mut entry_names: Vec<&str> = module
|
||||
.entry_points
|
||||
.iter()
|
||||
.map(|ep| ep.name.as_str())
|
||||
.collect();
|
||||
entry_names.sort();
|
||||
assert_eq!(entry_names, vec!["fs_main", "vs_main"]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user