1use bytemuck::{Pod, Zeroable};
2use glam::Mat4;
3use wgpu::*;
4
5pub const SKY_GROUND_WGSL: &str = include_str!("sky_ground.wgsl");
7
8#[derive(Copy, Clone, Debug, PartialEq)]
10pub struct Sky {
11 pub below: [f32; 3],
12 pub above: [f32; 3],
13}
14
15pub const DEFAULT_SKY: Sky = Sky {
16 below: [0.04, 0.05, 0.10],
17 above: [0.10, 0.13, 0.22],
18};
19
20impl Sky {
21 pub const fn horizon(self) -> Color {
23 Color {
24 r: 0.5 * (self.below[0] as f64 + self.above[0] as f64),
25 g: 0.5 * (self.below[1] as f64 + self.above[1] as f64),
26 b: 0.5 * (self.below[2] as f64 + self.above[2] as f64),
27 a: 1.0,
28 }
29 }
30}
31
32pub const SKY_HORIZON: Color = DEFAULT_SKY.horizon();
33
34pub fn linear_from_srgb8(rgb: [u8; 3]) -> [f32; 3] {
36 rgb.map(|byte| {
38 let c = f64::from(byte) / 255.0;
39 let linear = if c <= 0.04045 {
40 c / 12.92
41 } else {
42 ((c + 0.055) / 1.055).powf(2.4)
43 };
44 linear as f32
45 })
46}
47
48pub const GROUND_DARK_GREY: [f32; 3] = [0.18, 0.20, 0.24];
49pub const GROUND_LIGHT_GREY: [f32; 3] = [0.30, 0.32, 0.36];
50
51pub const DEFAULT_FOG_PER_UNIT: f32 = 0.0015;
53
54#[derive(Copy, Clone, Debug)]
55pub struct Ground {
56 pub y: f32,
57 pub dark: [f32; 3],
58 pub light: [f32; 3],
59 pub fog_per_unit: f32,
60 pub fog_start: f32,
62 pub visible: bool,
63}
64
65#[repr(C)]
67#[derive(Copy, Clone, Debug, Pod, Zeroable)]
68pub struct SkyGroundUniforms {
69 pub view_proj: [[f32; 4]; 4],
70 pub inv_view_proj: [[f32; 4]; 4],
71 pub viewport_origin: [f32; 2],
72 pub resolution: [f32; 2],
73 pub ground_dark: [f32; 3],
74 pub ground_y: f32,
75 pub ground_light: [f32; 3],
76 pub show_ground: f32,
77 pub sky_below: [f32; 3],
78 pub fog_per_unit: f32,
79 pub sky_above: [f32; 3],
80 pub fog_start: f32,
81}
82
83impl SkyGroundUniforms {
84 pub fn new(view_proj: Mat4, viewport: crate::Viewport, sky: Sky, ground: Ground) -> Self {
86 Self {
87 view_proj: view_proj.to_cols_array_2d(),
88 inv_view_proj: view_proj.inverse().to_cols_array_2d(),
89 viewport_origin: [viewport.x as f32, viewport.y as f32],
90 resolution: viewport.resolution_f32(),
91 ground_dark: ground.dark,
92 ground_y: ground.y,
93 ground_light: ground.light,
94 show_ground: if ground.visible { 1.0 } else { 0.0 },
95 sky_below: sky.below,
96 fog_per_unit: ground.fog_per_unit,
97 sky_above: sky.above,
98 fog_start: ground.fog_start,
99 }
100 }
101
102 fn sky(&self) -> Sky {
103 Sky {
104 below: self.sky_below,
105 above: self.sky_above,
106 }
107 }
108}
109
110const SKY_GROUND_NODE_WGSL: &str = concat!(
111 include_str!("sky_ground.wgsl"),
112 r#"
113struct Uniforms {
114 view_proj: mat4x4<f32>,
115 inv_view_proj: mat4x4<f32>,
116 viewport_origin: vec2<f32>,
117 resolution: vec2<f32>,
118 ground_dark: vec3<f32>,
119 ground_y: f32,
120 ground_light: vec3<f32>,
121 show_ground: f32,
122 sky_below: vec3<f32>,
123 fog_per_unit: f32,
124 sky_above: vec3<f32>,
125 fog_start: f32,
126};
127
128@group(0) @binding(0) var<uniform> u: Uniforms;
129
130const HORIZON_EPS: f32 = 1.0e-6;
131
132const LIGHT_DIR: vec3<f32> = vec3<f32>(0.5, 0.85, 0.3);
133const AMBIENT: f32 = 0.20;
134const DIFFUSE: f32 = 0.85;
135
136struct Fragment {
137 @location(0) color: vec4<f32>,
138 @builtin(frag_depth) depth: f32,
139};
140
141@vertex
142fn vs_fullscreen(@builtin(vertex_index) vid: u32) -> @builtin(position) vec4<f32> {
143 let uv = vec2<f32>(f32((vid << 1u) & 2u), f32(vid & 2u));
144 return vec4<f32>(uv * 2.0 - 1.0, 0.0, 1.0);
145}
146
147fn unproject(ndc: vec3<f32>) -> vec3<f32> {
148 let h = u.inv_view_proj * vec4<f32>(ndc, 1.0);
149 return h.xyz / h.w;
150}
151
152fn shade(frag_pos: vec4<f32>, near_ndc: f32, far_ndc: f32, background_depth: f32) -> Fragment {
153 let uv = (frag_pos.xy - u.viewport_origin) / u.resolution;
154 let ndc_xy = vec2<f32>(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0);
155 let near = unproject(vec3<f32>(ndc_xy, near_ndc));
156 // The infinite reversed projection puts the far point at w = 0, so the direction is formed before the divide.
157 let far = u.inv_view_proj * vec4<f32>(ndc_xy, far_ndc, 1.0);
158 let rd = normalize(far.xyz - near * far.w);
159 // Derivatives need uniform control flow, so the plane footprint is taken before the sky branch returns.
160 let plane_t = (u.ground_y - near.y) / select(rd.y, HORIZON_EPS, abs(rd.y) <= HORIZON_EPS);
161 let plane = (near + rd * plane_t).xz;
162 let footprint = abs(dpdx(plane)) + abs(dpdy(plane));
163
164 var out: Fragment;
165
166 var t: f32 = 0.0;
167 var hit = false;
168 if (u.show_ground >= 0.5 && abs(rd.y) > HORIZON_EPS) {
169 t = (u.ground_y - near.y) / rd.y;
170 hit = t > 0.0;
171 }
172
173 if (!hit) {
174 out.color = vec4<f32>(sky(rd, u.sky_below, u.sky_above), 1.0);
175 out.depth = background_depth;
176 return out;
177 }
178
179 let p_hit = near + rd * t;
180 let fog = 1.0 - exp(-max(t - u.fog_start, 0.0) * u.fog_per_unit);
181 let base = ground_color(p_hit, footprint, u.ground_dark, u.ground_light, fog);
182 let lambert = max(dot(vec3<f32>(0.0, 1.0, 0.0), normalize(LIGHT_DIR)), 0.0);
183 let lit = base * (AMBIENT + DIFFUSE * lambert);
184 out.color = vec4<f32>(mix(lit, sky(rd, u.sky_below, u.sky_above), fog), 1.0);
185
186 let clip = u.view_proj * vec4<f32>(p_hit, 1.0);
187 out.depth = clamp(clip.z / clip.w, 0.0, 1.0);
188 return out;
189}
190
191@fragment
192fn fs_main(@builtin(position) frag_pos: vec4<f32>) -> Fragment {
193 return shade(frag_pos, 0.0, 1.0, 1.0);
194}
195
196@fragment
197fn fs_reversed_z(@builtin(position) frag_pos: vec4<f32>) -> Fragment {
198 return shade(frag_pos, 1.0, 0.0, 0.0);
199}
200"#
201);
202
203pub struct SkyGroundNode {
204 pipeline: RenderPipeline,
205 uniform_buf: Buffer,
206 bind_group: BindGroup,
207 clear: Color,
208 depth_clear: f32,
209}
210
211impl SkyGroundNode {
212 pub fn new(
214 device: &Device,
215 target_format: TextureFormat,
216 depth_format: TextureFormat,
217 convention: crate::DepthConvention,
218 ) -> Self {
219 let module = device.create_shader_module(ShaderModuleDescriptor {
220 label: Some("sky_ground shader"),
221 source: ShaderSource::Wgsl(SKY_GROUND_NODE_WGSL.into()),
222 });
223
224 let uniform_buf = device.create_buffer(&BufferDescriptor {
225 label: Some("sky_ground uniforms"),
226 size: std::mem::size_of::<SkyGroundUniforms>() as u64,
227 usage: BufferUsages::UNIFORM | BufferUsages::COPY_DST,
228 mapped_at_creation: false,
229 });
230
231 let bgl = device.create_bind_group_layout(&BindGroupLayoutDescriptor {
232 label: Some("sky_ground bgl"),
233 entries: &[BindGroupLayoutEntry {
234 binding: 0,
235 visibility: ShaderStages::VERTEX_FRAGMENT,
236 ty: BindingType::Buffer {
237 ty: BufferBindingType::Uniform,
238 has_dynamic_offset: false,
239 min_binding_size: None,
240 },
241 count: None,
242 }],
243 });
244
245 let bind_group = device.create_bind_group(&BindGroupDescriptor {
246 label: Some("sky_ground bg"),
247 layout: &bgl,
248 entries: &[BindGroupEntry {
249 binding: 0,
250 resource: uniform_buf.as_entire_binding(),
251 }],
252 });
253
254 let pipeline_layout = device.create_pipeline_layout(&PipelineLayoutDescriptor {
255 label: Some("sky_ground pipeline layout"),
256 bind_group_layouts: &[&bgl],
257 push_constant_ranges: &[],
258 });
259
260 let pipeline = device.create_render_pipeline(&RenderPipelineDescriptor {
261 label: Some("sky_ground pipeline"),
262 layout: Some(&pipeline_layout),
263 vertex: VertexState {
264 module: &module,
265 entry_point: Some("vs_fullscreen"),
266 buffers: &[],
267 compilation_options: Default::default(),
268 },
269 fragment: Some(FragmentState {
270 module: &module,
271 entry_point: Some(match convention {
272 crate::DepthConvention::StandardZ => "fs_main",
273 crate::DepthConvention::ReversedZ => "fs_reversed_z",
274 }),
275 targets: &[Some(ColorTargetState {
276 format: target_format,
277 blend: None,
278 write_mask: ColorWrites::ALL,
279 })],
280 compilation_options: Default::default(),
281 }),
282 primitive: PrimitiveState {
283 topology: PrimitiveTopology::TriangleList,
284 ..Default::default()
285 },
286 depth_stencil: Some(DepthStencilState {
287 format: depth_format,
288 depth_write_enabled: true,
289 depth_compare: CompareFunction::Always,
290 stencil: StencilState::default(),
291 bias: DepthBiasState::default(),
292 }),
293 multisample: MultisampleState {
294 count: 1,
295 ..Default::default()
296 },
297 multiview: None,
298 cache: None,
299 });
300
301 Self {
302 pipeline,
303 uniform_buf,
304 bind_group,
305 clear: SKY_HORIZON,
306 depth_clear: match convention {
307 crate::DepthConvention::StandardZ => 1.0,
308 crate::DepthConvention::ReversedZ => crate::view::DEPTH_CLEAR,
309 },
310 }
311 }
312
313 pub fn set_uniforms(&mut self, queue: &Queue, uniforms: &SkyGroundUniforms) {
315 self.clear = uniforms.sky().horizon();
316 queue.write_buffer(&self.uniform_buf, 0, bytemuck::bytes_of(uniforms));
317 }
318
319 pub fn record(
321 &self,
322 encoder: &mut CommandEncoder,
323 view: &TextureView,
324 depth_view: &TextureView,
325 viewport: Option<&crate::Viewport>,
326 ) {
327 let mut rp = encoder.begin_render_pass(&RenderPassDescriptor {
328 label: Some("sky_ground pass"),
329 color_attachments: &[Some(RenderPassColorAttachment {
330 view,
331 depth_slice: None,
332 resolve_target: None,
333 ops: Operations {
334 load: LoadOp::Clear(self.clear),
335 store: StoreOp::Store,
336 },
337 })],
338 depth_stencil_attachment: Some(RenderPassDepthStencilAttachment {
339 view: depth_view,
340 depth_ops: Some(Operations {
341 load: LoadOp::Clear(self.depth_clear),
342 store: StoreOp::Store,
343 }),
344 stencil_ops: None,
345 }),
346 timestamp_writes: None,
347 occlusion_query_set: None,
348 });
349 self.draw(&mut rp, viewport);
350 }
351
352 pub fn draw(&self, rp: &mut RenderPass<'_>, viewport: Option<&crate::Viewport>) {
353 if let Some(vp) = viewport {
354 vp.apply(rp);
355 }
356 rp.set_pipeline(&self.pipeline);
357 rp.set_bind_group(0, &self.bind_group, &[]);
358 rp.draw(0..3, 0..1);
359 }
360}
361
362#[cfg(test)]
363mod tests {
364 use super::*;
365
366 #[test]
367 fn shader_validates_and_uniforms_match_host_layout() {
368 let module = naga::front::wgsl::parse_str(SKY_GROUND_NODE_WGSL).expect("parse");
369 naga::valid::Validator::new(
370 naga::valid::ValidationFlags::all(),
371 naga::valid::Capabilities::empty(),
372 )
373 .validate(&module)
374 .expect("sky shader validates");
375 let ty = module
376 .types
377 .iter()
378 .map(|(_, t)| t)
379 .find(|t| t.name.as_deref() == Some("Uniforms"))
380 .expect("the node's WGSL declares `Uniforms`");
381 let naga::TypeInner::Struct { members, span } = &ty.inner else {
382 panic!("`Uniforms` is not a struct");
383 };
384 assert_eq!(*span as usize, std::mem::size_of::<SkyGroundUniforms>());
385 let rust_offsets = [
386 (
387 "view_proj",
388 std::mem::offset_of!(SkyGroundUniforms, view_proj),
389 ),
390 (
391 "inv_view_proj",
392 std::mem::offset_of!(SkyGroundUniforms, inv_view_proj),
393 ),
394 (
395 "viewport_origin",
396 std::mem::offset_of!(SkyGroundUniforms, viewport_origin),
397 ),
398 (
399 "resolution",
400 std::mem::offset_of!(SkyGroundUniforms, resolution),
401 ),
402 (
403 "ground_dark",
404 std::mem::offset_of!(SkyGroundUniforms, ground_dark),
405 ),
406 (
407 "ground_y",
408 std::mem::offset_of!(SkyGroundUniforms, ground_y),
409 ),
410 (
411 "ground_light",
412 std::mem::offset_of!(SkyGroundUniforms, ground_light),
413 ),
414 (
415 "show_ground",
416 std::mem::offset_of!(SkyGroundUniforms, show_ground),
417 ),
418 (
419 "sky_below",
420 std::mem::offset_of!(SkyGroundUniforms, sky_below),
421 ),
422 (
423 "fog_per_unit",
424 std::mem::offset_of!(SkyGroundUniforms, fog_per_unit),
425 ),
426 (
427 "sky_above",
428 std::mem::offset_of!(SkyGroundUniforms, sky_above),
429 ),
430 (
431 "fog_start",
432 std::mem::offset_of!(SkyGroundUniforms, fog_start),
433 ),
434 ];
435 assert_eq!(members.len(), rust_offsets.len());
436 for (member, (name, offset)) in members.iter().zip(rust_offsets) {
437 assert_eq!(member.name.as_deref(), Some(name));
438 assert_eq!(member.offset as usize, offset, "offset of {name}");
439 }
440 }
441
442 #[test]
443 fn every_srgb_byte_survives_decode_and_the_targets_encode() {
444 for byte in 0..=u8::MAX {
445 let [linear, _, _] = linear_from_srgb8([byte; 3]);
446 let c = f64::from(linear);
447 let encoded = if c <= 0.003_130_8 {
448 12.92 * c
449 } else {
450 1.055 * c.powf(1.0 / 2.4) - 0.055
451 };
452 assert_eq!((encoded * 255.0).round() as u8, byte);
453 }
454 }
455}