1pub const WGSL_VERSION: u32 = 3;
33
34pub 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
208pub 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
377pub 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
420pub 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
438pub 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
495pub 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"#;