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 = 6;
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  | *(internal: CSM uniform; route through `viewport_sample_csm`)* | *(opaque)* |
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` |
59/// | 17 | point-light shadow cubemap array | `point_shadow_cube` |
60pub const SHARED_BINDINGS_WGSL: &str = r#"
61// @viewport-wgsl-version: 1
62// Shared group-0 declarations. Do not re-declare these bindings in plugin
63// shaders.
64
65struct Camera {
66    view_proj:     mat4x4<f32>,
67    eye_pos:       vec3<f32>,
68    _pad0:         f32,
69    forward:       vec3<f32>,
70    _pad1:         f32,
71    inv_view_proj: mat4x4<f32>,
72    view:          mat4x4<f32>,
73};
74
75struct SingleLight {
76    light_view_proj:   mat4x4<f32>,
77    pos_or_dir:        vec3<f32>,
78    light_type:        u32,
79    colour:            vec3<f32>,
80    intensity:         f32,
81    range:             f32,
82    inner_angle:       f32,
83    outer_angle:       f32,
84    spot_direction:    vec3<f32>,
85    point_shadow_slot: i32,
86    point_shadow_near: f32,
87    _pad0:             f32,
88    _pad1:             f32,
89};
90
91struct Lights {
92    count:                u32,
93    shadow_bias:          f32,
94    shadows_enabled:      u32,
95    debug_vis_mode:       u32,
96    sky_colour:           vec3<f32>,
97    hemisphere_intensity: f32,
98    ground_colour:        vec3<f32>,
99    debug_vis_scale:      f32,
100    ibl_enabled:          u32,
101    ibl_intensity:        f32,
102    ibl_rotation:         f32,
103    show_skybox:          u32,
104    debug_vis_split_x:    f32,
105    _pad_dbg_a:           u32,
106    _pad_dbg_b:           u32,
107    _pad_dbg_c:           u32,
108};
109
110struct ClipPlanes {
111    planes:          array<vec4<f32>, 6>,
112    count:           u32,
113    _pad0:           u32,
114    viewport_width:  f32,
115    viewport_height: f32,
116};
117
118struct ClipVolumeEntry {
119    volume_type:  u32,
120    _pad_a:       u32,
121    _pad_b:       u32,
122    _pad_c:       u32,
123    center:       vec3<f32>,
124    radius:       f32,
125    half_extents: vec3<f32>,
126    _pad1:        f32,
127    col0:         vec3<f32>,
128    _pad2:        f32,
129    col1:         vec3<f32>,
130    _pad3:        f32,
131    col2:         vec3<f32>,
132    _pad4:        f32,
133};
134
135struct ClipVolumeUB {
136    count:    u32,
137    _pad_a:   u32,
138    _pad_b:   u32,
139    _pad_c:   u32,
140    volumes:  array<ClipVolumeEntry, 4>,
141};
142
143@group(0) @binding(0)  var<uniform> camera:               Camera;
144@group(0) @binding(1)  var          shadow_atlas_tex:     texture_depth_2d;
145@group(0) @binding(2)  var          shadow_atlas_sampler: sampler_comparison;
146@group(0) @binding(3)  var<uniform> lights:               Lights;
147@group(0) @binding(4)  var<uniform> clip_planes:          ClipPlanes;
148@group(0) @binding(6)  var<uniform> clip_volume:          ClipVolumeUB;
149@group(0) @binding(7)  var          ibl_irradiance_tex:   texture_2d<f32>;
150@group(0) @binding(8)  var          ibl_specular_tex:     texture_2d<f32>;
151@group(0) @binding(9)  var          ibl_brdf_lut:         texture_2d<f32>;
152@group(0) @binding(10) var          ibl_sampler:          sampler;
153@group(0) @binding(11) var          skybox_tex:           texture_2d<f32>;
154@group(0) @binding(13) var<storage, read> lights_storage: array<SingleLight>;
155@group(0) @binding(17) var          point_shadow_cube:    texture_depth_cube_array;
156
157// Section-view clip planes: returns false when `world_pos` is on the
158// clipped side of any active plane. Plugin fragment shaders call this and
159// `discard` when it returns false to match the lib's clipping behaviour.
160fn viewport_pass_clip_planes(world_pos: vec3<f32>) -> bool {
161    for (var i = 0u; i < clip_planes.count; i = i + 1u) {
162        let plane = clip_planes.planes[i];
163        if dot(world_pos, plane.xyz) + plane.w < 0.0 {
164            return false;
165        }
166    }
167    return true;
168}
169
170// Composable clip volumes (box / sphere / cylinder): returns true when
171// `world_pos` is inside every active clip volume. Returns true when no
172// volumes are active.
173fn viewport_pass_clip_volumes(world_pos: vec3<f32>) -> bool {
174    for (var i = 0u; i < clip_volume.count; i = i + 1u) {
175        let e = clip_volume.volumes[i];
176        if e.volume_type == 2u {
177            let d = world_pos - e.center;
178            let local = vec3<f32>(dot(d, e.col0), dot(d, e.col1), dot(d, e.col2));
179            if abs(local.x) > e.half_extents.x
180                || abs(local.y) > e.half_extents.y
181                || abs(local.z) > e.half_extents.z {
182                return false;
183            }
184        } else if e.volume_type == 3u {
185            let ds = world_pos - e.center;
186            if dot(ds, ds) > e.radius * e.radius { return false; }
187        } else if e.volume_type == 4u {
188            let axis = e.col0;
189            let d = world_pos - e.center;
190            let along = dot(d, axis);
191            if abs(along) > e.half_extents.x { return false; }
192            let radial = d - axis * along;
193            if dot(radial, radial) > e.radius * e.radius { return false; }
194        }
195    }
196    return true;
197}
198
199// Combined clip test. Returns true when the fragment should be kept,
200// false when it should be discarded. Plugin fragment shaders typically:
201//
202//   if !viewport_clip_test(in.world_pos) { discard; }
203fn viewport_clip_test(world_pos: vec3<f32>) -> bool {
204    return viewport_pass_clip_planes(world_pos)
205        && viewport_pass_clip_volumes(world_pos);
206}
207
208// The shadow-info uniform (binding 5) is declared inside SHARED_PBR_WGSL
209// for the sole use of `viewport_sample_csm`. Its struct layout and field
210// set are intentionally not part of the published contract and may change
211// between catalog versions. Plugins must not redeclare binding 5 and must
212// not read the uniform directly; route shadow queries through
213// `viewport_sample_csm`.
214"#;
215
216/// Shared PBR shading helper.
217///
218/// Provides:
219///
220/// ```ignore
221/// fn viewport_pbr_shade(inp: PbrInputs) -> vec3<f32>;
222/// fn viewport_sample_csm(world_pos: vec3<f32>, world_normal: vec3<f32>) -> f32;
223/// fn viewport_apply_scene_lighting(N, base_colour, two_sided, world_pos) -> vec3<f32>;
224/// ```
225///
226/// `viewport_pbr_shade` returns the final lit colour for a fragment given a
227/// `PbrInputs` populated with albedo / normal / metallic / roughness / AO /
228/// emissive. It applies the lib's standard hemisphere ambient + light loop
229/// and attenuates the primary light's contribution by the CSM shadow factor
230/// when `lights.shadows_enabled != 0`. Plugins that compose this helper get
231/// shadows automatically; do not multiply by `viewport_sample_csm` again.
232/// Future revisions may add IBL and SSAO sampling inside this function;
233/// consumers should rebuild their shaders when the catalog version bumps to
234/// pick up the upgrade.
235///
236/// `viewport_sample_csm` returns a 0..1 shadow factor for `world_pos`.
237/// Returns 1.0 (fully lit) when shadows are disabled or the position is
238/// outside every cascade. The cascade scheme, filter kernel, and bias
239/// strategy are internal details and may change between catalog versions;
240/// the function signature and return-value semantics are the contract.
241///
242/// `viewport_apply_scene_lighting` is the simpler Lambert helper used by
243/// non-PBR pipelines (glyphs, tubes, ribbons). Use it when a plugin wants
244/// scene-light parity with those built-in items.
245pub const SHARED_PBR_WGSL: &str = r#"
246// @viewport-wgsl-version: 2
247// Shared PBR / lit-shading helpers. Requires SHARED_BINDINGS_WGSL to be
248// included first.
249//
250// Internal binding: the CSM uniform at @group(0) @binding(5) is declared
251// here for `viewport_sample_csm`'s use. Its struct layout is an internal
252// detail of this catalog; plugin shaders must not reference
253// `_viewport_csm` directly or assume the field set is stable.
254
255struct _ViewportCsm {
256    cascade_vp:        array<mat4x4<f32>, 4>,
257    cascade_splits:    vec4<f32>,
258    cascade_count:     u32,
259    atlas_size:        f32,
260    shadow_filter:     u32,
261    pcss_light_radius: f32,
262    atlas_rects:       array<vec4<f32>, 8>,
263};
264
265@group(0) @binding(5) var<uniform> _viewport_csm: _ViewportCsm;
266
267const _VIEWPORT_POISSON_DISK: array<vec2<f32>, 32> = array<vec2<f32>, 32>(
268    vec2<f32>(-0.94201624, -0.39906216), vec2<f32>( 0.94558609, -0.76890725),
269    vec2<f32>(-0.09418410, -0.92938870), vec2<f32>( 0.34495938,  0.29387760),
270    vec2<f32>(-0.91588581,  0.45771432), vec2<f32>(-0.81544232, -0.87912464),
271    vec2<f32>(-0.38277543,  0.27676845), vec2<f32>( 0.97484398,  0.75648379),
272    vec2<f32>( 0.44323325, -0.97511554), vec2<f32>( 0.53742981, -0.47373420),
273    vec2<f32>(-0.26496911, -0.41893023), vec2<f32>( 0.79197514,  0.19090188),
274    vec2<f32>(-0.24188840,  0.99706507), vec2<f32>(-0.81409955,  0.91437590),
275    vec2<f32>( 0.19984126,  0.78641367), vec2<f32>( 0.14383161, -0.14100790),
276    vec2<f32>(-0.44451570,  0.67055830), vec2<f32>( 0.70509040, -0.15854630),
277    vec2<f32>( 0.07130650, -0.64599580), vec2<f32>( 0.39881030,  0.55789810),
278    vec2<f32>(-0.60554040, -0.34964830), vec2<f32>( 0.85095100,  0.47178830),
279    vec2<f32>(-0.47994860,  0.08443340), vec2<f32>(-0.12494190, -0.76098760),
280    vec2<f32>( 0.64839320,  0.74738240), vec2<f32>(-0.96815740, -0.12345680),
281    vec2<f32>( 0.27682050, -0.80927180), vec2<f32>(-0.73016460,  0.18344200),
282    vec2<f32>( 0.54754660,  0.06234570), vec2<f32>(-0.30967360, -0.61021430),
283    vec2<f32>(-0.57774330,  0.80459740), vec2<f32>( 0.18238670, -0.37596540),
284);
285
286struct PbrInputs {
287    world_pos:  vec3<f32>,
288    world_n:    vec3<f32>,
289    view_dir:   vec3<f32>,
290    albedo:     vec3<f32>,
291    metallic:   f32,
292    roughness:  f32,
293    ao:         f32,
294    emissive:   vec3<f32>,
295};
296
297// Forward declaration: defined below; referenced by the lighting helpers.
298// The full definition appears after the lighting helpers for readability.
299// (WGSL allows module-scope identifiers to be used anywhere in the module
300// regardless of source order.)
301
302fn viewport_apply_scene_lighting(
303    normal: vec3<f32>,
304    base_colour: vec3<f32>,
305    two_sided: bool,
306    world_pos: vec3<f32>,
307) -> vec3<f32> {
308    let up_weight = clamp(normal.z * 0.5 + 0.5, 0.0, 1.0);
309    let ambient = mix(lights.ground_colour, lights.sky_colour, up_weight)
310                  * lights.hemisphere_intensity;
311
312    var direct = vec3<f32>(0.0);
313    let n_lights = lights.count;
314    for (var i: u32 = 0u; i < n_lights; i = i + 1u) {
315        let l = lights_storage[i];
316        var L: vec3<f32>;
317        var radiance: vec3<f32>;
318        if l.light_type == 0u {
319            L = normalize(l.pos_or_dir);
320            radiance = l.colour * l.intensity;
321        } else if l.light_type == 1u {
322            let to_light = l.pos_or_dir - world_pos;
323            let dist = length(to_light);
324            L = to_light / max(dist, 0.0001);
325            let falloff = clamp(1.0 - dist / l.range, 0.0, 1.0);
326            radiance = l.colour * l.intensity * falloff * falloff;
327        } else {
328            let to_light = l.pos_or_dir - world_pos;
329            let dist = length(to_light);
330            L = to_light / max(dist, 0.0001);
331            let dist_falloff = clamp(1.0 - dist / l.range, 0.0, 1.0);
332            let spot_dir = normalize(l.spot_direction);
333            let cos_angle = dot(-L, spot_dir);
334            let cos_outer = cos(l.outer_angle);
335            let cos_inner = cos(l.inner_angle);
336            let cone_att = clamp(
337                (cos_angle - cos_outer) / max(cos_inner - cos_outer, 0.0001),
338                0.0, 1.0,
339            );
340            radiance = l.colour * l.intensity * dist_falloff * dist_falloff * cone_att;
341        }
342        let raw = dot(normal, L);
343        let n_dot_l = select(max(raw, 0.0), abs(raw), two_sided);
344        var shadow_factor = 1.0;
345        if i == 0u {
346            shadow_factor = viewport_sample_csm(world_pos, normal);
347        }
348        direct = direct + radiance * n_dot_l * shadow_factor;
349    }
350    return base_colour * (ambient + direct);
351}
352
353// Cascade selection + atlas tap. Mirrors the bias scheme and filter
354// kernel used by the lib's built-in mesh pipeline so plugin items composite
355// consistently in the same scene. Implementation details (cascade count,
356// filter, bias) are internal and may change between catalog versions.
357fn viewport_sample_csm(world_pos: vec3<f32>, world_normal: vec3<f32>) -> f32 {
358    if lights.shadows_enabled == 0u || lights.count == 0u {
359        return 1.0;
360    }
361
362    let primary = lights_storage[0];
363    var light_dir: vec3<f32>;
364    if primary.light_type == 0u {
365        light_dir = normalize(primary.pos_or_dir);
366    } else {
367        let to_light = primary.pos_or_dir - world_pos;
368        light_dir = to_light / max(length(to_light), 0.0001);
369    }
370
371    let eye_pos = camera.eye_pos;
372    let dist = dot(world_pos - eye_pos, camera.forward);
373
374    var cascade_idx = 0u;
375    for (var i = 0u; i < _viewport_csm.cascade_count; i = i + 1u) {
376        if dist > _viewport_csm.cascade_splits[i] {
377            cascade_idx = i + 1u;
378        }
379    }
380    cascade_idx = min(cascade_idx, _viewport_csm.cascade_count - 1u);
381
382    let light_clip = _viewport_csm.cascade_vp[cascade_idx] * vec4<f32>(world_pos, 1.0);
383    let ndc = light_clip.xyz / light_clip.w;
384    let tile_uv = vec2<f32>(ndc.x * 0.5 + 0.5, -ndc.y * 0.5 + 0.5);
385
386    let rect = _viewport_csm.atlas_rects[cascade_idx];
387    let atlas_uv = vec2<f32>(
388        mix(rect.x, rect.z, tile_uv.x),
389        mix(rect.y, rect.w, tile_uv.y),
390    );
391
392    let n_dot_l = dot(world_normal, light_dir);
393    let offset_sign = select(-1.0, 1.0, n_dot_l >= 0.0);
394    let vp = _viewport_csm.cascade_vp[cascade_idx];
395    let vp_row0 = vec3<f32>(vp[0][0], vp[1][0], vp[2][0]);
396    let vp_row1 = vec3<f32>(vp[0][1], vp[1][1], vp[2][1]);
397    let vp_row2 = vec3<f32>(vp[0][2], vp[1][2], vp[2][2]);
398    let texel_world = 2.0 / (length(vp_row0) * _viewport_csm.atlas_size * (rect.z - rect.x));
399
400    let primary_light_type = primary.light_type;
401    var offset_world: vec3<f32>;
402    if primary_light_type == 0u {
403        let normal_bias = texel_world * 1.5;
404        offset_world = world_pos - light_dir * normal_bias;
405    } else {
406        let normal_bias = texel_world * mix(1.5, 0.0, clamp(abs(n_dot_l), 0.0, 1.0));
407        offset_world = world_pos + world_normal * (offset_sign * normal_bias);
408    }
409    let offset_clip = _viewport_csm.cascade_vp[cascade_idx] * vec4<f32>(offset_world, 1.0);
410    let biased_depth = (offset_clip.xyz / offset_clip.w).z - lights.shadow_bias;
411
412    if tile_uv.x < 0.0 || tile_uv.x > 1.0 || tile_uv.y < 0.0 || tile_uv.y > 1.0 ||
413       ndc.z < 0.0 || ndc.z > 1.0 {
414        return 1.0;
415    }
416
417    let n_ndc = vec3<f32>(
418        dot(vp_row0, world_normal) / dot(vp_row0, vp_row0),
419        dot(vp_row1, world_normal) / dot(vp_row1, vp_row1),
420        dot(vp_row2, world_normal) / dot(vp_row2, vp_row2),
421    );
422    let nz_sign = select(-1.0, 1.0, n_ndc.z >= 0.0);
423    let nz = nz_sign * max(abs(n_ndc.z), 1e-4);
424    let rp_gate = select(0.0, 1.0, primary_light_type == 0u);
425    let depth_grad = vec2<f32>(
426        -n_ndc.x / nz * 2.0 / (rect.z - rect.x),
427         n_ndc.y / nz * 2.0 / (rect.w - rect.y),
428    ) * rp_gate;
429
430    let texel_size = 1.0 / _viewport_csm.atlas_size;
431    let noise = fract(52.9829189 * fract(dot(world_pos.xz, vec2<f32>(0.06711056, 0.00583715))));
432    let rot = noise * 6.28318530;
433    let sin_r = sin(rot);
434    let cos_r = cos(rot);
435
436    if _viewport_csm.shadow_filter == 1u {
437        let search_radius = _viewport_csm.pcss_light_radius * 16.0 * texel_size;
438        var blocker_sum = 0.0;
439        var blocker_count = 0.0;
440        for (var i = 0u; i < 16u; i = i + 1u) {
441            let d = _VIEWPORT_POISSON_DISK[i];
442            let rd = vec2<f32>(d.x * cos_r - d.y * sin_r, d.x * sin_r + d.y * cos_r);
443            let sample_uv = atlas_uv + rd * search_radius;
444            let clamped_uv = clamp(sample_uv, rect.xy, rect.zw);
445            let coords = vec2<i32>(clamped_uv * _viewport_csm.atlas_size);
446            let raw_depth = textureLoad(shadow_atlas_tex, coords, 0);
447            if raw_depth < ndc.z {
448                blocker_sum = blocker_sum + raw_depth;
449                blocker_count = blocker_count + 1.0;
450            }
451        }
452        if blocker_count < 1.0 {
453            return 1.0;
454        }
455        let avg_blocker = blocker_sum / blocker_count;
456        let penumbra_width = _viewport_csm.pcss_light_radius * (biased_depth - avg_blocker) / max(avg_blocker, 0.001);
457        let filter_radius = max(penumbra_width * 16.0 * texel_size, texel_size);
458        var shadow = 0.0;
459        for (var i = 0u; i < 32u; i = i + 1u) {
460            let d = _VIEWPORT_POISSON_DISK[i];
461            let rd = vec2<f32>(d.x * cos_r - d.y * sin_r, d.x * sin_r + d.y * cos_r);
462            let sample_uv = atlas_uv + rd * filter_radius;
463            let clamped_uv = clamp(sample_uv, rect.xy, rect.zw);
464            let tap_depth = biased_depth
465                + clamp(dot(depth_grad, clamped_uv - atlas_uv), -0.005, 0.005);
466            shadow = shadow + textureSampleCompare(shadow_atlas_tex, shadow_atlas_sampler, clamped_uv, tap_depth);
467        }
468        return shadow / 32.0;
469    } else {
470        let pcf_radius = select(4.0, 1.5, primary_light_type == 0u) * texel_size;
471        var shadow = 0.0;
472        for (var i = 0u; i < 32u; i = i + 1u) {
473            let d = _VIEWPORT_POISSON_DISK[i];
474            let rd = vec2<f32>(d.x * cos_r - d.y * sin_r, d.x * sin_r + d.y * cos_r);
475            let sample_uv = atlas_uv + rd * pcf_radius;
476            let clamped_uv = clamp(sample_uv, rect.xy, rect.zw);
477            let tap_depth = biased_depth
478                + clamp(dot(depth_grad, clamped_uv - atlas_uv), -0.005, 0.005);
479            shadow = shadow + textureSampleCompare(shadow_atlas_tex, shadow_atlas_sampler, clamped_uv, tap_depth);
480        }
481        return shadow / 32.0;
482    }
483}
484
485// PBR shading. Cook-Torrance specular with GGX NDF + Smith G + Schlick
486// Fresnel, Lambert diffuse weighted by (1 - metallic). Integrates against
487// every active scene light; IBL contribution is added when
488// `lights.ibl_enabled != 0`.
489fn viewport_pbr_shade(inp: PbrInputs) -> vec3<f32> {
490    let N = normalize(inp.world_n);
491    let V = normalize(inp.view_dir);
492    let roughness = max(inp.roughness, 0.04);
493    let alpha = roughness * roughness;
494    let alpha2 = alpha * alpha;
495    let f0 = mix(vec3<f32>(0.04), inp.albedo, inp.metallic);
496
497    // Hemisphere ambient (kept for parity with non-PBR pipelines when IBL
498    // is disabled).
499    let up_weight = clamp(N.z * 0.5 + 0.5, 0.0, 1.0);
500    let ambient = mix(lights.ground_colour, lights.sky_colour, up_weight)
501                  * lights.hemisphere_intensity * inp.albedo * inp.ao;
502
503    var lo = vec3<f32>(0.0);
504    let n_lights = lights.count;
505    for (var i: u32 = 0u; i < n_lights; i = i + 1u) {
506        let l = lights_storage[i];
507        var L: vec3<f32>;
508        var radiance: vec3<f32>;
509        if l.light_type == 0u {
510            L = normalize(l.pos_or_dir);
511            radiance = l.colour * l.intensity;
512        } else if l.light_type == 1u {
513            let to_light = l.pos_or_dir - inp.world_pos;
514            let dist = length(to_light);
515            L = to_light / max(dist, 0.0001);
516            let falloff = clamp(1.0 - dist / l.range, 0.0, 1.0);
517            radiance = l.colour * l.intensity * falloff * falloff;
518        } else {
519            let to_light = l.pos_or_dir - inp.world_pos;
520            let dist = length(to_light);
521            L = to_light / max(dist, 0.0001);
522            let dist_falloff = clamp(1.0 - dist / l.range, 0.0, 1.0);
523            let spot_dir = normalize(l.spot_direction);
524            let cos_angle = dot(-L, spot_dir);
525            let cos_outer = cos(l.outer_angle);
526            let cos_inner = cos(l.inner_angle);
527            let cone_att = clamp(
528                (cos_angle - cos_outer) / max(cos_inner - cos_outer, 0.0001),
529                0.0, 1.0,
530            );
531            radiance = l.colour * l.intensity * dist_falloff * dist_falloff * cone_att;
532        }
533
534        let H = normalize(V + L);
535        let n_dot_l = max(dot(N, L), 0.0);
536        let n_dot_v = max(dot(N, V), 0.0001);
537        let n_dot_h = max(dot(N, H), 0.0);
538        let v_dot_h = max(dot(V, H), 0.0);
539
540        let denom = n_dot_h * n_dot_h * (alpha2 - 1.0) + 1.0;
541        let D = alpha2 / max(3.14159265 * denom * denom, 1e-6);
542        let k = (roughness + 1.0) * (roughness + 1.0) * 0.125;
543        let G1v = n_dot_v / (n_dot_v * (1.0 - k) + k);
544        let G1l = n_dot_l / max(n_dot_l * (1.0 - k) + k, 1e-6);
545        let G = G1v * G1l;
546        let F = f0 + (vec3<f32>(1.0) - f0) * pow(1.0 - v_dot_h, 5.0);
547        let spec = (D * G) * F / max(4.0 * n_dot_v * n_dot_l, 1e-6);
548
549        let kd = (vec3<f32>(1.0) - F) * (1.0 - inp.metallic);
550        let diff = kd * inp.albedo / 3.14159265;
551        var shadow_factor = 1.0;
552        if i == 0u {
553            shadow_factor = viewport_sample_csm(inp.world_pos, N);
554        }
555        lo = lo + (diff + spec) * radiance * n_dot_l * shadow_factor;
556    }
557
558    return ambient + lo + inp.emissive;
559}
560"#;
561
562/// Fragment-output struct and packing helper for the OIT pass.
563///
564/// A transparent plugin fragment shader returns [`OitOutput`] from its
565/// `fs_main`. Use `viewport_oit_pack(color_premul, alpha, view_z)` to
566/// build the struct; the weight function matches the lib's built-in OIT
567/// pipelines so plugin transparents composite consistently with native
568/// transparents in the same pass.
569pub const SHARED_OIT_WGSL: &str = r#"
570// @viewport-wgsl-version: 1
571// OIT MRT output struct and pack helper. Requires SHARED_BINDINGS_WGSL.
572//
573// Use as:
574//   @fragment
575//   fn fs_main(...) -> OitOutput {
576//       return viewport_oit_pack(color_rgb, alpha, in.view_z);
577//   }
578//
579// `view_z` is the view-space Z coordinate (negative in front of the
580// camera). The weight function biases nearer fragments toward higher
581// contribution, matching the weight curve in mesh_oit.wgsl.
582
583struct OitOutput {
584    @location(0) accum:  vec4<f32>,
585    @location(1) reveal: f32,
586};
587
588fn viewport_oit_weight(view_z: f32, alpha: f32) -> f32 {
589    // Weight curve from McGuire & Bavoil 2013, equation 7. Tuned for the
590    // lib's typical scene depth range.
591    let z = abs(view_z);
592    let w = alpha * clamp(10.0 / (1e-5 + pow(z / 5.0, 2.0) + pow(z / 200.0, 6.0)), 1e-2, 3e3);
593    return w;
594}
595
596fn viewport_oit_pack(color: vec3<f32>, alpha: f32, view_z: f32) -> OitOutput {
597    let w = viewport_oit_weight(view_z, alpha);
598    var out: OitOutput;
599    out.accum  = vec4<f32>(color * alpha * w, alpha * w);
600    out.reveal = alpha;
601    return out;
602}
603"#;
604
605/// Fragment helper for the outline mask pass.
606///
607/// A plugin's mask pipeline reuses its scene-pass vertex stage and uses
608/// `fs_mask` (or any function returning `@location(0) f32 = 1.0`). The
609/// composite reads any non-zero value as "this pixel belongs to a selected
610/// item." Depth state must match the mask pass: depth test on, depth write
611/// off.
612pub const SHARED_MASK_WGSL: &str = r#"
613// @viewport-wgsl-version: 1
614// Outline-mask fragment helper. Returns a single R8 value of 1.0 for any
615// covered pixel; the composite reads the mask and draws the outline edge.
616
617@fragment
618fn viewport_mask_fs() -> @location(0) f32 {
619    return 1.0;
620}
621"#;
622
623/// Fragment helper for the pick-id pass.
624///
625/// A plugin's pick pipeline reuses its scene-pass vertex stage (extended to
626/// pass a flat-interpolated `pick_id: u32`) and uses `fs_pick`. The
627/// renderer reads back the R32U pixel under the cursor to resolve which
628/// item was clicked.
629pub const SHARED_PICK_WGSL: &str = r#"
630// @viewport-wgsl-version: 1
631// Pick-id fragment helper. The vertex stage must provide a flat-interpolated
632// pick_id at @location(0) of the fragment input; see your pipeline's vertex
633// shader for the matching declaration.
634
635@fragment
636fn viewport_pick_fs(
637    @location(0) @interpolate(flat) pick_id: u32,
638) -> @location(0) u32 {
639    return pick_id;
640}
641"#;