Skip to main content

viewport_lib/plugin_api/
shared_wgsl.rs

1//! WGSL helper catalog.
2//!
3//! Each helper is a `&'static str` of WGSL that a plugin shader prefixes (or
4//! concatenates into its own shader source) to gain access to the lib's
5//! shared bindings, shading helpers, and target conventions.
6//!
7//! Versioning: each helper carries a `// @viewport-wgsl-version: N`
8//! comment. The version is bumped whenever a function signature, struct
9//! field, or binding number changes. Plugins compare against
10//! [`WGSL_VERSION`] at build time to detect breakage early. Function bodies
11//! and private fields are not part of the contract and may change between
12//! patch releases.
13//!
14//! Composition: plugin shaders typically build their source as:
15//!
16//! ```ignore
17//! let src = format!(
18//!     "{bindings}\n{pbr}\n{this_pipeline_specific_wgsl}",
19//!     bindings = viewport_lib::plugin_api::shared_wgsl::SHARED_BINDINGS_WGSL,
20//!     pbr      = viewport_lib::plugin_api::shared_wgsl::SHARED_PBR_WGSL,
21//!     this_pipeline_specific_wgsl = include_str!("my_shader.wgsl"),
22//! );
23//! ```
24
25/// Catalog version. Bumped on any breaking change to a helper signature,
26/// struct field, or binding number. Plugins should assert against this at
27/// build time:
28///
29/// ```ignore
30/// const _: () = assert!(viewport_lib::plugin_api::shared_wgsl::WGSL_VERSION == 1);
31/// ```
32pub const WGSL_VERSION: u32 = 3;
33
34/// Group-0 bind declarations and shared scene-data structs.
35///
36/// Declares every binding in the lib's camera/lights/shadows/clip/IBL group,
37/// matching the layout exposed via
38/// [`SharedBindings`](super::SharedBindings). Plugin shaders include this
39/// once and must not re-declare these bindings.
40///
41/// Bindings exposed:
42///
43/// | Binding | Resource | WGSL identifier |
44/// |---------|----------|----------------|
45/// | 0  | `Camera` uniform | `camera` |
46/// | 1  | shadow atlas texture | `shadow_atlas_tex` |
47/// | 2  | shadow comparison sampler | `shadow_atlas_sampler` |
48/// | 3  | `Lights` header uniform | `lights` |
49/// | 4  | `ClipPlanes` uniform | `clip_planes` |
50/// | 5  | `ShadowInfo` uniform (CSM matrices, splits) | `shadow_info` |
51/// | 6  | `ClipVolumes` uniform | `clip_volumes` |
52/// | 7  | IBL irradiance equirect | `ibl_irradiance_tex` |
53/// | 8  | IBL specular equirect | `ibl_specular_tex` |
54/// | 9  | BRDF integration LUT | `ibl_brdf_lut` |
55/// | 10 | IBL sampler | `ibl_sampler` |
56/// | 11 | Skybox equirect | `skybox_tex` |
57/// | 12 | debug fragment storage buffer | `debug_frag` |
58/// | 13 | per-light array | `lights_storage` |
59pub const SHARED_BINDINGS_WGSL: &str = r#"
60// @viewport-wgsl-version: 1
61// Shared group-0 declarations. Do not re-declare these bindings in plugin
62// shaders.
63
64struct Camera {
65    view_proj:     mat4x4<f32>,
66    eye_pos:       vec3<f32>,
67    _pad0:         f32,
68    forward:       vec3<f32>,
69    _pad1:         f32,
70    inv_view_proj: mat4x4<f32>,
71    view:          mat4x4<f32>,
72};
73
74struct SingleLight {
75    light_view_proj: mat4x4<f32>,
76    pos_or_dir:      vec3<f32>,
77    light_type:      u32,
78    colour:          vec3<f32>,
79    intensity:       f32,
80    range:           f32,
81    inner_angle:     f32,
82    outer_angle:     f32,
83    spot_direction:  vec3<f32>,
84    _pad:            vec2<f32>,
85};
86
87struct Lights {
88    count:                u32,
89    shadow_bias:          f32,
90    shadows_enabled:      u32,
91    debug_vis_mode:       u32,
92    sky_colour:           vec3<f32>,
93    hemisphere_intensity: f32,
94    ground_colour:        vec3<f32>,
95    debug_vis_scale:      f32,
96    ibl_enabled:          u32,
97    ibl_intensity:        f32,
98    ibl_rotation:         f32,
99    show_skybox:          u32,
100    debug_vis_split_x:    f32,
101    _pad_dbg_a:           u32,
102    _pad_dbg_b:           u32,
103    _pad_dbg_c:           u32,
104};
105
106struct ClipPlanes {
107    planes:          array<vec4<f32>, 6>,
108    count:           u32,
109    _pad0:           u32,
110    viewport_width:  f32,
111    viewport_height: f32,
112};
113
114struct ClipVolumeEntry {
115    volume_type:  u32,
116    _pad_a:       u32,
117    _pad_b:       u32,
118    _pad_c:       u32,
119    center:       vec3<f32>,
120    radius:       f32,
121    half_extents: vec3<f32>,
122    _pad1:        f32,
123    col0:         vec3<f32>,
124    _pad2:        f32,
125    col1:         vec3<f32>,
126    _pad3:        f32,
127    col2:         vec3<f32>,
128    _pad4:        f32,
129};
130
131struct ClipVolumeUB {
132    count:    u32,
133    _pad_a:   u32,
134    _pad_b:   u32,
135    _pad_c:   u32,
136    volumes:  array<ClipVolumeEntry, 4>,
137};
138
139@group(0) @binding(0)  var<uniform> camera:               Camera;
140@group(0) @binding(1)  var          shadow_atlas_tex:     texture_depth_2d;
141@group(0) @binding(2)  var          shadow_atlas_sampler: sampler_comparison;
142@group(0) @binding(3)  var<uniform> lights:               Lights;
143@group(0) @binding(4)  var<uniform> clip_planes:          ClipPlanes;
144@group(0) @binding(6)  var<uniform> clip_volume:          ClipVolumeUB;
145@group(0) @binding(7)  var          ibl_irradiance_tex:   texture_2d<f32>;
146@group(0) @binding(8)  var          ibl_specular_tex:     texture_2d<f32>;
147@group(0) @binding(9)  var          ibl_brdf_lut:         texture_2d<f32>;
148@group(0) @binding(10) var          ibl_sampler:          sampler;
149@group(0) @binding(11) var          skybox_tex:           texture_2d<f32>;
150@group(0) @binding(13) var<storage, read> lights_storage: array<SingleLight>;
151
152// Section-view clip planes: returns false when `world_pos` is on the
153// clipped side of any active plane. Plugin fragment shaders call this and
154// `discard` when it returns false to match the lib's clipping behaviour.
155fn viewport_pass_clip_planes(world_pos: vec3<f32>) -> bool {
156    for (var i = 0u; i < clip_planes.count; i = i + 1u) {
157        let plane = clip_planes.planes[i];
158        if dot(world_pos, plane.xyz) + plane.w < 0.0 {
159            return false;
160        }
161    }
162    return true;
163}
164
165// Composable clip volumes (box / sphere / cylinder): returns true when
166// `world_pos` is inside every active clip volume. Returns true when no
167// volumes are active.
168fn viewport_pass_clip_volumes(world_pos: vec3<f32>) -> bool {
169    for (var i = 0u; i < clip_volume.count; i = i + 1u) {
170        let e = clip_volume.volumes[i];
171        if e.volume_type == 2u {
172            let d = world_pos - e.center;
173            let local = vec3<f32>(dot(d, e.col0), dot(d, e.col1), dot(d, e.col2));
174            if abs(local.x) > e.half_extents.x
175                || abs(local.y) > e.half_extents.y
176                || abs(local.z) > e.half_extents.z {
177                return false;
178            }
179        } else if e.volume_type == 3u {
180            let ds = world_pos - e.center;
181            if dot(ds, ds) > e.radius * e.radius { return false; }
182        } else if e.volume_type == 4u {
183            let axis = e.col0;
184            let d = world_pos - e.center;
185            let along = dot(d, axis);
186            if abs(along) > e.half_extents.x { return false; }
187            let radial = d - axis * along;
188            if dot(radial, radial) > e.radius * e.radius { return false; }
189        }
190    }
191    return true;
192}
193
194// Combined clip test. Returns true when the fragment should be kept,
195// false when it should be discarded. Plugin fragment shaders typically:
196//
197//   if !viewport_clip_test(in.world_pos) { discard; }
198fn viewport_clip_test(world_pos: vec3<f32>) -> bool {
199    return viewport_pass_clip_planes(world_pos)
200        && viewport_pass_clip_volumes(world_pos);
201}
202
203// The shadow-info uniform (binding 5) is opaque to plugins; sample via
204// `viewport_sample_csm` from SHARED_PBR_WGSL rather than reading the
205// raw uniform directly.
206"#;
207
208/// Shared PBR shading helper.
209///
210/// Provides:
211///
212/// ```ignore
213/// fn viewport_pbr_shade(inp: PbrInputs) -> vec3<f32>;
214/// fn viewport_sample_csm(world_pos: vec3<f32>, world_normal: vec3<f32>) -> f32;
215/// fn viewport_apply_scene_lighting(N, base_colour, two_sided, world_pos) -> vec3<f32>;
216/// ```
217///
218/// `viewport_pbr_shade` returns the final lit colour for a fragment given a
219/// `PbrInputs` populated with albedo / normal / metallic / roughness / AO /
220/// emissive. It applies the lib's standard hemisphere ambient + light loop +
221/// (optionally) shadow attenuation. Future revisions may add IBL and SSAO
222/// sampling inside this function; consumers should rebuild their shaders
223/// when the catalog version bumps to pick up the upgrade.
224///
225/// `viewport_sample_csm` returns a 0..1 shadow factor for `world_pos`.
226/// Returns 1.0 (fully lit) when shadows are disabled or the position is
227/// outside every cascade.
228///
229/// `viewport_apply_scene_lighting` is the simpler Lambert helper used by
230/// non-PBR pipelines (glyphs, tubes, ribbons). Use it when a plugin wants
231/// scene-light parity with those built-in items.
232pub const SHARED_PBR_WGSL: &str = r#"
233// @viewport-wgsl-version: 1
234// Shared PBR / lit-shading helpers. Requires SHARED_BINDINGS_WGSL to be
235// included first.
236
237struct PbrInputs {
238    world_pos:  vec3<f32>,
239    world_n:    vec3<f32>,
240    view_dir:   vec3<f32>,
241    albedo:     vec3<f32>,
242    metallic:   f32,
243    roughness:  f32,
244    ao:         f32,
245    emissive:   vec3<f32>,
246};
247
248fn viewport_apply_scene_lighting(
249    normal: vec3<f32>,
250    base_colour: vec3<f32>,
251    two_sided: bool,
252    world_pos: vec3<f32>,
253) -> vec3<f32> {
254    let up_weight = clamp(normal.z * 0.5 + 0.5, 0.0, 1.0);
255    let ambient = mix(lights.ground_colour, lights.sky_colour, up_weight)
256                  * lights.hemisphere_intensity;
257
258    var direct = vec3<f32>(0.0);
259    let n_lights = lights.count;
260    for (var i: u32 = 0u; i < n_lights; i = i + 1u) {
261        let l = lights_storage[i];
262        var L: vec3<f32>;
263        var radiance: vec3<f32>;
264        if l.light_type == 0u {
265            L = normalize(l.pos_or_dir);
266            radiance = l.colour * l.intensity;
267        } else if l.light_type == 1u {
268            let to_light = l.pos_or_dir - world_pos;
269            let dist = length(to_light);
270            L = to_light / max(dist, 0.0001);
271            let falloff = clamp(1.0 - dist / l.range, 0.0, 1.0);
272            radiance = l.colour * l.intensity * falloff * falloff;
273        } else {
274            let to_light = l.pos_or_dir - world_pos;
275            let dist = length(to_light);
276            L = to_light / max(dist, 0.0001);
277            let dist_falloff = clamp(1.0 - dist / l.range, 0.0, 1.0);
278            let spot_dir = normalize(l.spot_direction);
279            let cos_angle = dot(-L, spot_dir);
280            let cos_outer = cos(l.outer_angle);
281            let cos_inner = cos(l.inner_angle);
282            let cone_att = clamp(
283                (cos_angle - cos_outer) / max(cos_inner - cos_outer, 0.0001),
284                0.0, 1.0,
285            );
286            radiance = l.colour * l.intensity * dist_falloff * dist_falloff * cone_att;
287        }
288        let raw = dot(normal, L);
289        let n_dot_l = select(max(raw, 0.0), abs(raw), two_sided);
290        direct = direct + radiance * n_dot_l;
291    }
292    return base_colour * (ambient + direct);
293}
294
295// Placeholder shadow sampler. The full CSM tap requires the shadow_info
296// uniform layout, which is not yet part of the published group-0 contract;
297// while it stabilises this function returns 1.0 (fully lit). A future
298// catalog version will wire this to the atlas tap once the layout is
299// frozen.
300fn viewport_sample_csm(world_pos: vec3<f32>, world_normal: vec3<f32>) -> f32 {
301    return 1.0;
302}
303
304// PBR shading. Cook-Torrance specular with GGX NDF + Smith G + Schlick
305// Fresnel, Lambert diffuse weighted by (1 - metallic). Integrates against
306// every active scene light; IBL contribution is added when
307// `lights.ibl_enabled != 0`.
308fn viewport_pbr_shade(inp: PbrInputs) -> vec3<f32> {
309    let N = normalize(inp.world_n);
310    let V = normalize(inp.view_dir);
311    let roughness = max(inp.roughness, 0.04);
312    let alpha = roughness * roughness;
313    let alpha2 = alpha * alpha;
314    let f0 = mix(vec3<f32>(0.04), inp.albedo, inp.metallic);
315
316    // Hemisphere ambient (kept for parity with non-PBR pipelines when IBL
317    // is disabled).
318    let up_weight = clamp(N.z * 0.5 + 0.5, 0.0, 1.0);
319    let ambient = mix(lights.ground_colour, lights.sky_colour, up_weight)
320                  * lights.hemisphere_intensity * inp.albedo * inp.ao;
321
322    var lo = vec3<f32>(0.0);
323    let n_lights = lights.count;
324    for (var i: u32 = 0u; i < n_lights; i = i + 1u) {
325        let l = lights_storage[i];
326        var L: vec3<f32>;
327        var radiance: vec3<f32>;
328        if l.light_type == 0u {
329            L = normalize(l.pos_or_dir);
330            radiance = l.colour * l.intensity;
331        } else if l.light_type == 1u {
332            let to_light = l.pos_or_dir - inp.world_pos;
333            let dist = length(to_light);
334            L = to_light / max(dist, 0.0001);
335            let falloff = clamp(1.0 - dist / l.range, 0.0, 1.0);
336            radiance = l.colour * l.intensity * falloff * falloff;
337        } else {
338            let to_light = l.pos_or_dir - inp.world_pos;
339            let dist = length(to_light);
340            L = to_light / max(dist, 0.0001);
341            let dist_falloff = clamp(1.0 - dist / l.range, 0.0, 1.0);
342            let spot_dir = normalize(l.spot_direction);
343            let cos_angle = dot(-L, spot_dir);
344            let cos_outer = cos(l.outer_angle);
345            let cos_inner = cos(l.inner_angle);
346            let cone_att = clamp(
347                (cos_angle - cos_outer) / max(cos_inner - cos_outer, 0.0001),
348                0.0, 1.0,
349            );
350            radiance = l.colour * l.intensity * dist_falloff * dist_falloff * cone_att;
351        }
352
353        let H = normalize(V + L);
354        let n_dot_l = max(dot(N, L), 0.0);
355        let n_dot_v = max(dot(N, V), 0.0001);
356        let n_dot_h = max(dot(N, H), 0.0);
357        let v_dot_h = max(dot(V, H), 0.0);
358
359        let denom = n_dot_h * n_dot_h * (alpha2 - 1.0) + 1.0;
360        let D = alpha2 / max(3.14159265 * denom * denom, 1e-6);
361        let k = (roughness + 1.0) * (roughness + 1.0) * 0.125;
362        let G1v = n_dot_v / (n_dot_v * (1.0 - k) + k);
363        let G1l = n_dot_l / max(n_dot_l * (1.0 - k) + k, 1e-6);
364        let G = G1v * G1l;
365        let F = f0 + (vec3<f32>(1.0) - f0) * pow(1.0 - v_dot_h, 5.0);
366        let spec = (D * G) * F / max(4.0 * n_dot_v * n_dot_l, 1e-6);
367
368        let kd = (vec3<f32>(1.0) - F) * (1.0 - inp.metallic);
369        let diff = kd * inp.albedo / 3.14159265;
370        lo = lo + (diff + spec) * radiance * n_dot_l;
371    }
372
373    return ambient + lo + inp.emissive;
374}
375"#;
376
377/// Fragment-output struct and packing helper for the OIT pass.
378///
379/// A transparent plugin fragment shader returns [`OitOutput`] from its
380/// `fs_main`. Use `viewport_oit_pack(color_premul, alpha, view_z)` to
381/// build the struct; the weight function matches the lib's built-in OIT
382/// pipelines so plugin transparents composite consistently with native
383/// transparents in the same pass.
384pub const SHARED_OIT_WGSL: &str = r#"
385// @viewport-wgsl-version: 1
386// OIT MRT output struct and pack helper. Requires SHARED_BINDINGS_WGSL.
387//
388// Use as:
389//   @fragment
390//   fn fs_main(...) -> OitOutput {
391//       return viewport_oit_pack(color_rgb, alpha, in.view_z);
392//   }
393//
394// `view_z` is the view-space Z coordinate (negative in front of the
395// camera). The weight function biases nearer fragments toward higher
396// contribution, matching the weight curve in mesh_oit.wgsl.
397
398struct OitOutput {
399    @location(0) accum:  vec4<f32>,
400    @location(1) reveal: f32,
401};
402
403fn viewport_oit_weight(view_z: f32, alpha: f32) -> f32 {
404    // Weight curve from McGuire & Bavoil 2013, equation 7. Tuned for the
405    // lib's typical scene depth range.
406    let z = abs(view_z);
407    let w = alpha * clamp(10.0 / (1e-5 + pow(z / 5.0, 2.0) + pow(z / 200.0, 6.0)), 1e-2, 3e3);
408    return w;
409}
410
411fn viewport_oit_pack(color: vec3<f32>, alpha: f32, view_z: f32) -> OitOutput {
412    let w = viewport_oit_weight(view_z, alpha);
413    var out: OitOutput;
414    out.accum  = vec4<f32>(color * alpha * w, alpha * w);
415    out.reveal = alpha;
416    return out;
417}
418"#;
419
420/// Fragment helper for the outline mask pass.
421///
422/// A plugin's mask pipeline reuses its scene-pass vertex stage and uses
423/// `fs_mask` (or any function returning `@location(0) f32 = 1.0`). The
424/// composite reads any non-zero value as "this pixel belongs to a selected
425/// item." Depth state must match the mask pass: depth test on, depth write
426/// off.
427pub const SHARED_MASK_WGSL: &str = r#"
428// @viewport-wgsl-version: 1
429// Outline-mask fragment helper. Returns a single R8 value of 1.0 for any
430// covered pixel; the composite reads the mask and draws the outline edge.
431
432@fragment
433fn viewport_mask_fs() -> @location(0) f32 {
434    return 1.0;
435}
436"#;
437
438/// Vertex-stage helper for GPU skinning.
439///
440/// Declares the lib's skinning bind group at `@group(2)` and a
441/// `viewport_skin_matrix(vertex_index) -> mat4x4<f32>` helper that builds
442/// the per-vertex skinning matrix from the bound weights + palette.
443/// Plugin pipelines that want to ship skinned variants add
444/// [`ViewportGpuResources::skin_palette_layout`](crate::resources::ViewportGpuResources::skin_palette_layout)
445/// to their `extra_bind_group_layouts` list as group 2 and call this
446/// helper from their vertex shader.
447///
448/// The palette is uploaded per-instance by the host via
449/// [`ViewportGpuResources::set_skin_palette`](crate::resources::ViewportGpuResources::set_skin_palette);
450/// plugins do not manage palette uploads.
451pub const SHARED_SKIN_WGSL: &str = r#"
452// @viewport-wgsl-version: 1
453// GPU skinning bind group + matrix-blend helper.
454//
455// Expects the host to bind the lib's skin bind group at @group(2):
456//   @binding(0): array<SkinVertex>     (per-vertex weights + 4 joint indices)
457//   @binding(1): array<mat4x4<f32>>    (per-instance joint palette)
458//
459// Plugin vertex shaders that include this helper must also forward a
460// `vertex_index` builtin to the position transform:
461//
462//   let skin_m   = viewport_skin_matrix(in.vertex_index);
463//   let skin_pos = (skin_m * vec4(in.position, 1.0)).xyz;
464//   let skin_n   = normalize((skin_m * vec4(in.normal, 0.0)).xyz);
465
466struct SkinVertex {
467    weights:    vec4<f32>,
468    joints_01:  u32,
469    joints_23:  u32,
470};
471
472@group(2) @binding(0) var<storage, read> skin_weights: array<SkinVertex>;
473@group(2) @binding(1) var<storage, read> skin_palette: array<mat4x4<f32>>;
474
475fn viewport_unpack_joint(skin: SkinVertex, slot: u32) -> u32 {
476    if slot == 0u { return skin.joints_01 & 0xFFFFu; }
477    if slot == 1u { return (skin.joints_01 >> 16u) & 0xFFFFu; }
478    if slot == 2u { return skin.joints_23 & 0xFFFFu; }
479    return (skin.joints_23 >> 16u) & 0xFFFFu;
480}
481
482fn viewport_skin_matrix(vertex_index: u32) -> mat4x4<f32> {
483    let s = skin_weights[vertex_index];
484    let j0 = viewport_unpack_joint(s, 0u);
485    let j1 = viewport_unpack_joint(s, 1u);
486    let j2 = viewport_unpack_joint(s, 2u);
487    let j3 = viewport_unpack_joint(s, 3u);
488    return skin_palette[j0] * s.weights.x
489         + skin_palette[j1] * s.weights.y
490         + skin_palette[j2] * s.weights.z
491         + skin_palette[j3] * s.weights.w;
492}
493"#;
494
495/// Fragment helper for the pick-id pass.
496///
497/// A plugin's pick pipeline reuses its scene-pass vertex stage (extended to
498/// pass a flat-interpolated `pick_id: u32`) and uses `fs_pick`. The
499/// renderer reads back the R32U pixel under the cursor to resolve which
500/// item was clicked.
501pub const SHARED_PICK_WGSL: &str = r#"
502// @viewport-wgsl-version: 1
503// Pick-id fragment helper. The vertex stage must provide a flat-interpolated
504// pick_id at @location(0) of the fragment input; see your pipeline's vertex
505// shader for the matching declaration.
506
507@fragment
508fn viewport_pick_fs(
509    @location(0) @interpolate(flat) pick_id: u32,
510) -> @location(0) u32 {
511    return pick_id;
512}
513"#;