@aethervtt/shard-render 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (139) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +3 -0
  3. package/dist/assets.d.ts +104 -0
  4. package/dist/assets.js +213 -0
  5. package/dist/atmosphere-model.d.ts +96 -0
  6. package/dist/atmosphere-model.js +370 -0
  7. package/dist/atmosphere-nodes.d.ts +55 -0
  8. package/dist/atmosphere-nodes.js +780 -0
  9. package/dist/atmosphere-plugin.d.ts +5 -0
  10. package/dist/atmosphere-plugin.js +42 -0
  11. package/dist/atmosphere-shaders.d.ts +7 -0
  12. package/dist/atmosphere-shaders.js +595 -0
  13. package/dist/atmosphere-state.d.ts +72 -0
  14. package/dist/atmosphere-state.js +18 -0
  15. package/dist/atmosphere.d.ts +191 -0
  16. package/dist/atmosphere.js +950 -0
  17. package/dist/camera.d.ts +83 -0
  18. package/dist/camera.js +163 -0
  19. package/dist/clusters.d.ts +68 -0
  20. package/dist/clusters.js +288 -0
  21. package/dist/culling-shaders.d.ts +2 -0
  22. package/dist/culling-shaders.js +203 -0
  23. package/dist/culling.d.ts +84 -0
  24. package/dist/culling.js +498 -0
  25. package/dist/debug-shaders.d.ts +3 -0
  26. package/dist/debug-shaders.js +207 -0
  27. package/dist/debug-views.d.ts +60 -0
  28. package/dist/debug-views.js +197 -0
  29. package/dist/deferred-plugin.d.ts +5 -0
  30. package/dist/deferred-plugin.js +20 -0
  31. package/dist/deferred-shaders.d.ts +2 -0
  32. package/dist/deferred-shaders.js +151 -0
  33. package/dist/deferred.d.ts +22 -0
  34. package/dist/deferred.js +296 -0
  35. package/dist/deform.d.ts +80 -0
  36. package/dist/deform.js +213 -0
  37. package/dist/describe-plugin.d.ts +5 -0
  38. package/dist/describe-plugin.js +19 -0
  39. package/dist/display-nodes.d.ts +5 -0
  40. package/dist/display-nodes.js +189 -0
  41. package/dist/dynamic-resolution.d.ts +47 -0
  42. package/dist/dynamic-resolution.js +191 -0
  43. package/dist/environment-plugin.d.ts +6 -0
  44. package/dist/environment-plugin.js +44 -0
  45. package/dist/environment-shaders.d.ts +2 -0
  46. package/dist/environment-shaders.js +260 -0
  47. package/dist/environment-state.d.ts +71 -0
  48. package/dist/environment-state.js +144 -0
  49. package/dist/environment.d.ts +70 -0
  50. package/dist/environment.js +529 -0
  51. package/dist/forward.d.ts +123 -0
  52. package/dist/forward.js +1216 -0
  53. package/dist/fxaa.d.ts +2 -0
  54. package/dist/fxaa.js +65 -0
  55. package/dist/gizmo-font.d.ts +21 -0
  56. package/dist/gizmo-font.js +132 -0
  57. package/dist/gizmos-plugin.d.ts +5 -0
  58. package/dist/gizmos-plugin.js +34 -0
  59. package/dist/gizmos.d.ts +156 -0
  60. package/dist/gizmos.js +772 -0
  61. package/dist/gpu-assets.d.ts +114 -0
  62. package/dist/gpu-assets.js +494 -0
  63. package/dist/graph.d.ts +204 -0
  64. package/dist/graph.js +417 -0
  65. package/dist/index.d.ts +53 -0
  66. package/dist/index.js +540 -0
  67. package/dist/instances.d.ts +349 -0
  68. package/dist/instances.js +1335 -0
  69. package/dist/large-world.d.ts +13 -0
  70. package/dist/large-world.js +20 -0
  71. package/dist/lights.d.ts +160 -0
  72. package/dist/lights.js +468 -0
  73. package/dist/material-noise.d.ts +19 -0
  74. package/dist/material-noise.js +7 -0
  75. package/dist/material-pipelines.d.ts +46 -0
  76. package/dist/material-pipelines.js +166 -0
  77. package/dist/materials.d.ts +108 -0
  78. package/dist/materials.js +219 -0
  79. package/dist/noise.d.ts +76 -0
  80. package/dist/noise.js +286 -0
  81. package/dist/overlay-registry.d.ts +18 -0
  82. package/dist/overlay-registry.js +30 -0
  83. package/dist/overlays.d.ts +35 -0
  84. package/dist/overlays.js +344 -0
  85. package/dist/paths.d.ts +20 -0
  86. package/dist/paths.js +46 -0
  87. package/dist/picking-plugin.d.ts +2 -0
  88. package/dist/picking-plugin.js +20 -0
  89. package/dist/picking.d.ts +94 -0
  90. package/dist/picking.js +456 -0
  91. package/dist/pixel-perfect-plugin.d.ts +9 -0
  92. package/dist/pixel-perfect-plugin.js +135 -0
  93. package/dist/pixel-perfect.d.ts +27 -0
  94. package/dist/pixel-perfect.js +70 -0
  95. package/dist/plugin.d.ts +111 -0
  96. package/dist/plugin.js +182 -0
  97. package/dist/pool.d.ts +19 -0
  98. package/dist/pool.js +43 -0
  99. package/dist/post-common.d.ts +51 -0
  100. package/dist/post-common.js +148 -0
  101. package/dist/post-nodes.d.ts +39 -0
  102. package/dist/post-nodes.js +849 -0
  103. package/dist/post-plugin.d.ts +5 -0
  104. package/dist/post-plugin.js +26 -0
  105. package/dist/post-shaders.d.ts +8 -0
  106. package/dist/post-shaders.js +698 -0
  107. package/dist/post.d.ts +175 -0
  108. package/dist/post.js +453 -0
  109. package/dist/readback.d.ts +4 -0
  110. package/dist/readback.js +95 -0
  111. package/dist/render-scale.d.ts +53 -0
  112. package/dist/render-scale.js +63 -0
  113. package/dist/shaders.d.ts +122 -0
  114. package/dist/shaders.js +1168 -0
  115. package/dist/shadows.d.ts +119 -0
  116. package/dist/shadows.js +562 -0
  117. package/dist/skin-asset.d.ts +26 -0
  118. package/dist/skin-asset.js +25 -0
  119. package/dist/skinning-plugin.d.ts +5 -0
  120. package/dist/skinning-plugin.js +16 -0
  121. package/dist/skinning.d.ts +19 -0
  122. package/dist/skinning.js +343 -0
  123. package/dist/standard-fields.d.ts +67 -0
  124. package/dist/standard-fields.js +73 -0
  125. package/dist/standard.d.ts +7 -0
  126. package/dist/standard.js +37 -0
  127. package/dist/stats.d.ts +25 -0
  128. package/dist/stats.js +18 -0
  129. package/dist/target.d.ts +55 -0
  130. package/dist/target.js +70 -0
  131. package/dist/testing.d.ts +34 -0
  132. package/dist/testing.js +126 -0
  133. package/dist/timer.d.ts +28 -0
  134. package/dist/timer.js +136 -0
  135. package/dist/view.d.ts +117 -0
  136. package/dist/view.js +389 -0
  137. package/dist/visibility.d.ts +10 -0
  138. package/dist/visibility.js +84 -0
  139. package/package.json +49 -0
@@ -0,0 +1,1168 @@
1
+ import { defineComponent, t } from "@aethervtt/shard-core";
2
+ import { wgslLayout } from "@aethervtt/shard-shader";
3
+ import { StandardMaterial } from "./assets.js";
4
+ import { CULLING_SHADERS } from "./culling-shaders.js";
5
+ const ViewUniform = defineComponent("render/ViewUniform", {
6
+ viewProj: t.mat4,
7
+ view: t.mat4,
8
+ invViewProj: t.mat4,
9
+ cameraPosition: t.vec3,
10
+ /** Scene luminance to pre-exposed HDR: what the HDR target stores. */
11
+ exposure: t.f32,
12
+ /** width, height, 1/width, 1/height. */
13
+ viewport: t.vec4,
14
+ /** Cluster slices: near, far, slices / ln(far / near), debug mode (1 clusters, 2 cascades). */
15
+ clusterParams: t.vec4,
16
+ /** Ambient luminance (cd/m²), linear color. */
17
+ ambient: t.vec3,
18
+ /** Environment: intensity (cd/m² per unit), cos and sin of its rotation, enabled (w). */
19
+ envParams: t.vec4,
20
+ /** viewProj without the TAA jitter, this frame and last (motion vectors). */
21
+ viewProjNoJitter: t.mat4,
22
+ prevViewProj: t.mat4,
23
+ /** TAA jitter in pixels (xy), frames rendered (z). */
24
+ jitter: t.vec4,
25
+ /**
26
+ * Per directional light (column i): the atmosphere's transmittance (rgb) between the camera and
27
+ * that sun, applied to its light (spec 0044). All ones without an atmosphere.
28
+ */
29
+ sunTransmittance: t.mat4
30
+ });
31
+ const viewLayout = wgslLayout(ViewUniform);
32
+ const materialLayout = wgslLayout(StandardMaterial);
33
+ const ENGINE_SHADERS = {
34
+ "shard::view": `${viewLayout.wgsl}
35
+ @group(0) @binding(0) var<uniform> view: ViewUniform;`,
36
+ "shard::pbr::standard_material": materialLayout.wgsl,
37
+ "shard::pbr::types": `
38
+ struct VertexOutput {
39
+ @builtin(position) clip: vec4f,
40
+ @location(0) world_position: vec3f,
41
+ @location(1) world_normal: vec3f,
42
+ @location(2) uv: vec2f,
43
+ @location(3) uv1: vec2f,
44
+ /** xyz: world tangent (zero when the mesh has none), w: bitangent sign. */
45
+ @location(4) world_tangent: vec4f,
46
+ /** Instance flags (shard::mesh::FLAG_*). */
47
+ @location(5) @interpolate(flat) flags: u32,
48
+ /** Material-defined, from the vertex_extra hook (zero unless a material overrides it). */
49
+ @location(6) extra: vec4f,
50
+ }
51
+
52
+ /** What a material produces; all lighting works from this. */
53
+ struct PbrInput {
54
+ base_color: vec3f,
55
+ alpha: f32,
56
+ normal: vec3f,
57
+ metallic: f32,
58
+ roughness: f32,
59
+ emissive: vec3f,
60
+ occlusion: f32,
61
+ }`,
62
+ "shard::mesh": `
63
+ import shard::pbr::types::VertexOutput;
64
+
65
+ /** One persistent instance slot: affine rows, batch, flags, packed visibility range, entity. */
66
+ struct Instance {
67
+ row0: vec4f,
68
+ row1: vec4f,
69
+ row2: vec4f,
70
+ batch: u32,
71
+ flags: u32,
72
+ range: u32,
73
+ entity: u32,
74
+ }
75
+
76
+ const FLAG_VISIBLE: u32 = 1u;
77
+ const FLAG_CASTER: u32 = 2u;
78
+ const FLAG_RECEIVER: u32 = 4u;
79
+
80
+ @group(2) @binding(0) var<storage, read> instances: array<Instance>;
81
+ /** The view's culled slots; a draw's instance_index indexes this. */
82
+ @group(2) @binding(1) var<storage, read> visible: array<u32>;
83
+
84
+ /** Visible-list entries: slot in the low 28 bits, LOD level in the top 4. */
85
+ const SLOT_MASK: u32 = 0x0fffffffu;
86
+
87
+ fn instance_at(instance_index: u32) -> Instance {
88
+ let entry = visible[instance_index];
89
+ var inst = instances[entry & SLOT_MASK];
90
+ // The LOD level rides in the flags' top byte, for debug views.
91
+ inst.flags = inst.flags | ((entry >> 28u) << 24u);
92
+ return inst;
93
+ }
94
+
95
+ fn instance_world(inst: Instance, position: vec3f) -> vec3f {
96
+ let p = vec4f(position, 1.0);
97
+ return vec3f(dot(inst.row0, p), dot(inst.row1, p), dot(inst.row2, p));
98
+ }
99
+
100
+ /** Last frame's transform of each slot (three affine rows), for motion vectors. */
101
+ @group(2) @binding(2) var<storage, read> previous: array<vec4f>;
102
+
103
+ /** Where an object-space position was last frame, in world space. */
104
+ fn previous_world(instance_index: u32, position: vec3f) -> vec3f {
105
+ let slot = visible[instance_index] & SLOT_MASK;
106
+ let p = vec4f(position, 1.0);
107
+ return vec3f(dot(previous[slot * 3u], p), dot(previous[slot * 3u + 1u], p), dot(previous[slot * 3u + 2u], p));
108
+ }
109
+
110
+ /** The vertex mesh_vertex is processing, for vertex_position overrides (see vertex_uv1). */
111
+ var<private> mesh_current_instance: Instance;
112
+ var<private> mesh_current_uv1: vec2f;
113
+ var<private> mesh_current_tangent: vec4f;
114
+
115
+ /** In vertex_position: the vertex's second uv set. */
116
+ fn vertex_uv1() -> vec2f {
117
+ return mesh_current_uv1;
118
+ }
119
+
120
+ /** In vertex_position: the vertex's tangent (xyz, handedness in w) after deformation. */
121
+ fn vertex_tangent() -> vec4f {
122
+ return mesh_current_tangent;
123
+ }
124
+
125
+ /** In vertex_position: the instance's render/InstanceData (x, y). */
126
+ fn vertex_instance_data() -> vec2f {
127
+ return unpack2x16float(mesh_current_instance.range);
128
+ }
129
+
130
+ /** In vertex_position: an object-space point in world space, by this instance's transform. */
131
+ fn vertex_world(p: vec3f) -> vec3f {
132
+ return instance_world(mesh_current_instance, p);
133
+ }
134
+
135
+ /**
136
+ * Object-space displacement, applied in every pass that draws the mesh (forward, depth, shadows,
137
+ * G-buffer), so a swaying mesh's shadow sways with it. Override it in a material's shader; it can
138
+ * read the vertex's uv1 and tangent and its world position with vertex_uv1(), vertex_tangent(),
139
+ * and vertex_world(p).
140
+ */
141
+ @hook fn vertex_position(position: vec3f, normal: vec3f, uv: vec2f) -> vec3f {
142
+ return position;
143
+ }
144
+
145
+ /**
146
+ * Anything a material's surface stage needs from its vertices beyond the standard outputs, handed
147
+ * to it as VertexOutput.extra (interpolated). Override it next to vertex_position; it can read the
148
+ * same accessors (vertex_uv1(), vertex_tangent(), vertex_instance_data()).
149
+ */
150
+ @hook fn vertex_extra(position: vec3f, normal: vec3f, uv: vec2f) -> vec4f {
151
+ return vec4f(0.0);
152
+ }
153
+
154
+ /**
155
+ * Skinned and morphed slots (see deform.ts). Records per slot; poses hold this frame's joint
156
+ * matrices (three affine rows each) and morph (target, weight) pairs; deform_data holds each mesh's
157
+ * joints and weights (6 words per vertex) and morph deltas (9 floats per vertex per target).
158
+ */
159
+ struct Deform {
160
+ sphere: vec4f,
161
+ joint_base: u32,
162
+ joint_count: u32,
163
+ skin_base: u32,
164
+ morph_base: u32,
165
+ vertex_count: u32,
166
+ morph_count: u32,
167
+ weights_base: u32,
168
+ _pad: u32,
169
+ }
170
+
171
+ const FLAG_SKINNED: u32 = 32u;
172
+ const FLAG_MORPH: u32 = 64u;
173
+
174
+ @group(2) @binding(3) var<storage, read> deforms: array<Deform>;
175
+ @group(2) @binding(4) var<storage, read> poses: array<vec4f>;
176
+ @group(2) @binding(5) var<storage, read> deform_data: array<u32>;
177
+
178
+ struct Deformed {
179
+ position: vec3f,
180
+ normal: vec3f,
181
+ tangent: vec4f,
182
+ }
183
+
184
+ fn deform_f32(i: u32) -> f32 {
185
+ return bitcast<f32>(deform_data[i]);
186
+ }
187
+
188
+ fn deform_vec3(i: u32) -> vec3f {
189
+ return vec3f(deform_f32(i), deform_f32(i + 1u), deform_f32(i + 2u));
190
+ }
191
+
192
+ /** Morph targets, then skinning (linear blend, 4 influences), in the mesh's object space. */
193
+ fn deform_vertex(instance_index: u32, vertex_index: u32, position: vec3f, normal: vec3f, tangent: vec4f) -> Deformed {
194
+ var out: Deformed;
195
+ out.position = position;
196
+ out.normal = normal;
197
+ out.tangent = tangent;
198
+ let slot = visible[instance_index] & SLOT_MASK;
199
+ let flags = instances[slot].flags;
200
+ if ((flags & (FLAG_SKINNED | FLAG_MORPH)) == 0u) { return out; }
201
+ let d = deforms[slot];
202
+ if (vertex_index >= d.vertex_count) { return out; }
203
+ for (var k = 0u; k < d.morph_count; k++) {
204
+ let pair = poses[d.weights_base + k / 2u];
205
+ let odd = (k & 1u) == 1u;
206
+ let target_index = u32(select(pair.x, pair.z, odd));
207
+ let w = select(pair.y, pair.w, odd);
208
+ let o = d.morph_base + (target_index * d.vertex_count + vertex_index) * 9u;
209
+ out.position += deform_vec3(o) * w;
210
+ out.normal += deform_vec3(o + 3u) * w;
211
+ out.tangent = vec4f(out.tangent.xyz + deform_vec3(o + 6u) * w, out.tangent.w);
212
+ }
213
+ if ((flags & FLAG_SKINNED) != 0u && d.joint_count > 0u) {
214
+ let s = d.skin_base + vertex_index * 6u;
215
+ let j01 = deform_data[s];
216
+ let j23 = deform_data[s + 1u];
217
+ let joints = vec4u(j01 & 0xffffu, j01 >> 16u, j23 & 0xffffu, j23 >> 16u);
218
+ let weights = vec4f(deform_f32(s + 2u), deform_f32(s + 3u), deform_f32(s + 4u), deform_f32(s + 5u));
219
+ var r0 = vec4f(0.0);
220
+ var r1 = vec4f(0.0);
221
+ var r2 = vec4f(0.0);
222
+ for (var i = 0u; i < 4u; i++) {
223
+ let m = d.joint_base + min(joints[i], d.joint_count - 1u) * 3u;
224
+ r0 += poses[m] * weights[i];
225
+ r1 += poses[m + 1u] * weights[i];
226
+ r2 += poses[m + 2u] * weights[i];
227
+ }
228
+ let p = vec4f(out.position, 1.0);
229
+ out.position = vec3f(dot(r0, p), dot(r1, p), dot(r2, p));
230
+ out.normal = vec3f(dot(r0.xyz, out.normal), dot(r1.xyz, out.normal), dot(r2.xyz, out.normal));
231
+ let t = out.tangent.xyz;
232
+ out.tangent = vec4f(dot(r0.xyz, t), dot(r1.xyz, t), dot(r2.xyz, t), out.tangent.w);
233
+ }
234
+ return out;
235
+ }
236
+
237
+ /** Object space to world space, with normals through the cofactor (correct under any scale). */
238
+ fn mesh_vertex(inst: Instance, position: vec3f, normal: vec3f, uv: vec2f, uv1: vec2f, tangent: vec4f) -> VertexOutput {
239
+ mesh_current_instance = inst;
240
+ mesh_current_uv1 = uv1;
241
+ mesh_current_tangent = tangent;
242
+ let world = instance_world(inst, vertex_position(position, normal, uv));
243
+ let c0 = vec3f(inst.row0.x, inst.row1.x, inst.row2.x);
244
+ let c1 = vec3f(inst.row0.y, inst.row1.y, inst.row2.y);
245
+ let c2 = vec3f(inst.row0.z, inst.row1.z, inst.row2.z);
246
+ let n = normal.x * cross(c1, c2) + normal.y * cross(c2, c0) + normal.z * cross(c0, c1);
247
+ var out: VertexOutput;
248
+ out.world_position = world;
249
+ out.world_normal = normalize(n);
250
+ out.uv = uv;
251
+ out.uv1 = uv1;
252
+ let wt = c0 * tangent.x + c1 * tangent.y + c2 * tangent.z;
253
+ out.world_tangent = vec4f(select(vec3f(0.0), normalize(wt), dot(wt, wt) > 1e-12), tangent.w);
254
+ out.flags = inst.flags;
255
+ out.extra = vertex_extra(position, normal, uv);
256
+ return out;
257
+ }
258
+
259
+ /** mesh_vertex for a draw's vertex: deformed first when the slot is skinned or morphed. */
260
+ fn mesh_vertex_at(instance_index: u32, vertex_index: u32, position: vec3f, normal: vec3f, uv: vec2f, uv1: vec2f, tangent: vec4f) -> VertexOutput {
261
+ let d = deform_vertex(instance_index, vertex_index, position, normal, tangent);
262
+ return mesh_vertex(instance_at(instance_index), d.position, d.normal, uv, uv1, d.tangent);
263
+ }`,
264
+ "shard::pbr::standard": `
265
+ import shard::pbr::types::{ VertexOutput, PbrInput };
266
+ import shard::pbr::standard_material::StandardMaterial;
267
+
268
+ @group(1) @binding(0) var<uniform> material: StandardMaterial;
269
+
270
+ /** Per slot: a = (offset.xy, scale.xy); b = (rotation, uv set, has texture, 0). */
271
+ struct TextureSlot { a: vec4f, b: vec4f }
272
+ struct MaterialTextures { slots: array<TextureSlot, 5> }
273
+ @group(1) @binding(1) var<uniform> material_textures: MaterialTextures;
274
+ @group(1) @binding(2) var base_color_texture: texture_2d<f32>;
275
+ @group(1) @binding(3) var metallic_roughness_texture: texture_2d<f32>;
276
+ @group(1) @binding(4) var normal_texture: texture_2d<f32>;
277
+ @group(1) @binding(5) var occlusion_texture: texture_2d<f32>;
278
+ @group(1) @binding(6) var emissive_texture: texture_2d<f32>;
279
+ @group(1) @binding(7) var base_color_sampler: sampler;
280
+ @group(1) @binding(8) var metallic_roughness_sampler: sampler;
281
+ @group(1) @binding(9) var normal_sampler: sampler;
282
+ @group(1) @binding(10) var occlusion_sampler: sampler;
283
+ @group(1) @binding(11) var emissive_sampler: sampler;
284
+
285
+ /** A slot's UVs: its UV set, then KHR_texture_transform (translation * rotation * scale). */
286
+ fn slot_uv(index: u32, in: VertexOutput) -> vec2f {
287
+ let slot = material_textures.slots[index];
288
+ let uv = select(in.uv, in.uv1, slot.b.y > 0.5) * slot.a.zw;
289
+ let c = cos(slot.b.x);
290
+ let s = sin(slot.b.x);
291
+ // Rotation direction verified against Khronos' TextureTransformTest (arrows hit "Correct").
292
+ return vec2f(c * uv.x + s * uv.y, -s * uv.x + c * uv.y) + slot.a.xy;
293
+ }
294
+
295
+ /** What StandardMaterial makes of a surface. Material shaders call it and adjust the result. */
296
+ fn standard_input(in: VertexOutput) -> PbrInput {
297
+ // Empty slots bind 1x1 defaults (white, flat normal), so sampling is always valid.
298
+ let base = textureSample(base_color_texture, base_color_sampler, slot_uv(0u, in));
299
+ let mr = textureSample(metallic_roughness_texture, metallic_roughness_sampler, slot_uv(1u, in));
300
+ let occ = textureSample(occlusion_texture, occlusion_sampler, slot_uv(3u, in));
301
+ let emit = textureSample(emissive_texture, emissive_sampler, slot_uv(4u, in));
302
+ let nm = textureSample(normal_texture, normal_sampler, slot_uv(2u, in));
303
+ var p: PbrInput;
304
+ p.base_color = material.baseColor.rgb * base.rgb;
305
+ p.alpha = material.baseColor.a * base.a;
306
+ var n = normalize(in.world_normal);
307
+ let t = in.world_tangent.xyz;
308
+ if (material_textures.slots[2].b.z > 0.5 && dot(t, t) > 1e-8) {
309
+ // Tangent-space normal: z rebuilt from xy, so two-channel formats (BC5, EAC RG11) work too.
310
+ let xy = (nm.xy * 2.0 - 1.0) * material.normalScale;
311
+ let z = sqrt(max(0.0, 1.0 - dot(xy, xy)));
312
+ let tangent = normalize(t - n * dot(n, t));
313
+ let bitangent = cross(n, tangent) * in.world_tangent.w;
314
+ n = normalize(tangent * xy.x + bitangent * xy.y + n * z);
315
+ }
316
+ p.normal = n;
317
+ p.metallic = material.metallic * mr.b;
318
+ p.roughness = clamp(material.roughness * mr.g, 0.045, 1.0);
319
+ p.emissive = material.emissive.rgb * material.emissiveLuminance * emit.rgb;
320
+ p.occlusion = mix(1.0, occ.r, material.occlusionStrength);
321
+ return p;
322
+ }
323
+
324
+ `,
325
+ "shard::pbr::material": `
326
+ import shard::pbr::types::{ VertexOutput, PbrInput };
327
+ import shard::pbr::standard::standard_input;
328
+
329
+ /** Surface stage. Override this hook to change what a surface looks like, not how it's lit. */
330
+ @hook fn pbr_input(in: VertexOutput) -> PbrInput {
331
+ return standard_input(in);
332
+ }
333
+
334
+ /** Last chance to change a fragment's color: pre-exposed HDR, before tonemapping. */
335
+ @hook fn fragment_output(color: vec4f) -> vec4f {
336
+ return color;
337
+ }`,
338
+ "shard::globals": `
339
+ struct Globals {
340
+ /** Seconds since the app started. */
341
+ time: f32,
342
+ delta_time: f32,
343
+ frame: u32,
344
+ _pad: u32,
345
+ }
346
+
347
+ @group(0) @binding(14) var<uniform> globals: Globals;`,
348
+ "shard::unlit::shading": `
349
+ import shard::pbr::types::VertexOutput;
350
+
351
+ /**
352
+ * The whole fragment for materials with extends: 'none': returns scene-referred radiance (cd/m\xB2)
353
+ * and alpha. No lighting runs; the engine only applies exposure.
354
+ */
355
+ @hook fn shade(in: VertexOutput) -> vec4f {
356
+ return vec4f(1.0, 0.0, 1.0, 1.0);
357
+ }`,
358
+ "shard::unlit::forward": `
359
+ import shard::view::view;
360
+ import shard::pbr::types::VertexOutput;
361
+ import shard::mesh::mesh_vertex_at;
362
+ import shard::unlit::shading::shade;
363
+
364
+ @vertex fn vs(
365
+ @builtin(instance_index) instance_index: u32,
366
+ @builtin(vertex_index) vertex_index: u32,
367
+ @location(0) position: vec3f,
368
+ @location(1) normal: vec3f,
369
+ @location(2) uv: vec2f,
370
+ @location(3) uv1: vec2f,
371
+ @location(4) tangent: vec4f,
372
+ ) -> VertexOutput {
373
+ var out = mesh_vertex_at(instance_index, vertex_index, position, normal, uv, uv1, tangent);
374
+ out.clip = view.viewProj * vec4f(out.world_position, 1.0);
375
+ return out;
376
+ }
377
+
378
+ @fragment fn fs(in: VertexOutput) -> @location(0) vec4f {
379
+ let c = shade(in);
380
+ @if(MASK) if (c.a < 0.5) { discard; }
381
+ return vec4f(c.rgb * view.exposure, c.a);
382
+ }`,
383
+ "shard::pbr::brdf": `
384
+ const PI: f32 = 3.14159265359;
385
+
386
+ fn d_ggx(n_dot_h: f32, a: f32) -> f32 {
387
+ let a2 = a * a;
388
+ let d = n_dot_h * n_dot_h * (a2 - 1.0) + 1.0;
389
+ return a2 / (PI * d * d);
390
+ }
391
+
392
+ fn v_smith_ggx_correlated(n_dot_v: f32, n_dot_l: f32, a: f32) -> f32 {
393
+ let a2 = a * a;
394
+ let gv = n_dot_l * sqrt(n_dot_v * n_dot_v * (1.0 - a2) + a2);
395
+ let gl = n_dot_v * sqrt(n_dot_l * n_dot_l * (1.0 - a2) + a2);
396
+ return 0.5 / (gv + gl);
397
+ }
398
+
399
+ fn f_schlick(f0: vec3f, v_dot_h: f32) -> vec3f {
400
+ return f0 + (vec3f(1.0) - f0) * pow(1.0 - v_dot_h, 5.0);
401
+ }`,
402
+ "shard::pbr::lights": `
403
+ /** A clustered light (point or spot). 80 bytes; packed by LightStore. */
404
+ struct Light {
405
+ position: vec3f,
406
+ range: f32,
407
+ /** Color times luminous intensity (candela = lm / 4\u03C0). */
408
+ color: vec3f,
409
+ radius: f32,
410
+ /** Spot axis: the direction light travels. */
411
+ direction: vec3f,
412
+ kind: u32,
413
+ spot_scale: f32,
414
+ spot_offset: f32,
415
+ shadow: u32,
416
+ shadow_bias: f32,
417
+ shadow_normal_bias: f32,
418
+ shadow_softness: f32,
419
+ _pad0: f32,
420
+ _pad1: f32,
421
+ }
422
+
423
+ struct DirectionalLight {
424
+ /** Toward the light. */
425
+ direction: vec3f,
426
+ shadowed: u32,
427
+ /** Color times illuminance (lux), at the top of any atmosphere. */
428
+ color: vec3f,
429
+ /** Sun disk angular radius (rad). */
430
+ angular_radius: f32,
431
+ }
432
+
433
+ struct DirectionalLights {
434
+ count: u32,
435
+ _pad0: u32,
436
+ _pad1: u32,
437
+ _pad2: u32,
438
+ lights: array<DirectionalLight, 4>,
439
+ }
440
+
441
+ const LIGHT_SPOT: u32 = 1u;
442
+ const NO_SHADOW: u32 = 0xffffffffu;
443
+ const CLUSTER_X: u32 = 16u;
444
+ const CLUSTER_Y: u32 = 9u;
445
+ const CLUSTER_Z: u32 = 24u;
446
+ const CLUSTER_COUNT: u32 = 3456u;
447
+ const MAX_PER_CLUSTER: u32 = 128u;
448
+
449
+ @group(0) @binding(1) var<storage, read> lights: array<Light>;
450
+ /** counts[CLUSTER_COUNT], then MAX_PER_CLUSTER light indices per cluster. */
451
+ @group(0) @binding(2) var<storage, read> clusters: array<u32>;
452
+ @group(0) @binding(3) var<storage, read> directional: DirectionalLights;`,
453
+ "shard::pbr::shadows": `
454
+ import shard::color::ign;
455
+
456
+ struct ShadowData {
457
+ cascade_view_proj: array<mat4x4f, 4>,
458
+ /** Far view depth of each cascade. */
459
+ cascade_splits: vec4f,
460
+ /** World size of one texel, per cascade. */
461
+ cascade_texel: vec4f,
462
+ /** count, bias (m), normal bias (texels), softness. */
463
+ cascade_params: vec4f,
464
+ /** Toward the light (xyz), enabled (w). */
465
+ cascade_light: vec4f,
466
+ spot_view_proj: array<mat4x4f, 8>,
467
+ /** Position (xyz), near plane (w). */
468
+ point_position: array<vec4f, 4>,
469
+ point_view_proj: array<mat4x4f, 24>,
470
+ /** Cascade map size, local map size, tan(half angle) of point faces. */
471
+ sizes: vec4f,
472
+ /** tan(half angle), near plane. */
473
+ spot_params: array<vec4f, 8>,
474
+ }
475
+
476
+ @group(0) @binding(4) var<storage, read> shadows: ShadowData;
477
+ @group(0) @binding(5) var cascade_maps: texture_depth_2d_array;
478
+ @group(0) @binding(6) var spot_maps: texture_depth_2d_array;
479
+ @group(0) @binding(7) var point_maps: texture_depth_2d_array;
480
+ @group(0) @binding(8) var shadow_sampler: sampler_comparison;
481
+
482
+ const POISSON: array<vec2f, 16> = array<vec2f, 16>(
483
+ vec2f(-0.94201624, -0.39906216), vec2f(0.94558609, -0.76890725),
484
+ vec2f(-0.09418410, -0.92938870), vec2f(0.34495938, 0.29387760),
485
+ vec2f(-0.91588581, 0.45771432), vec2f(-0.81544232, -0.87912464),
486
+ vec2f(-0.38277543, 0.27676845), vec2f(0.97484398, 0.75648379),
487
+ vec2f(0.44323325, -0.97511554), vec2f(0.53742981, -0.47373420),
488
+ vec2f(-0.26496911, -0.41893023), vec2f(0.79197514, 0.19090188),
489
+ vec2f(-0.24188840, 0.99706507), vec2f(-0.81409955, 0.91437590),
490
+ vec2f(0.19984126, 0.78641367), vec2f(0.14383161, -0.14100790),
491
+ );
492
+
493
+ /**
494
+ * Percentage-closer filtering on a comparison sampler (reversed-Z: lit where the receiver is at
495
+ * least as close as the occluder). 3x3 bilinear taps; with softness, a Poisson disk rotated per pixel.
496
+ */
497
+ fn pcf(map: texture_depth_2d_array, layer: i32, uv: vec2f, depth: f32, size: f32, softness: f32, frag: vec2f) -> f32 {
498
+ let texel = 1.0 / size;
499
+ var sum = 0.0;
500
+ if (softness <= 0.0) {
501
+ for (var y = -1; y <= 1; y++) {
502
+ for (var x = -1; x <= 1; x++) {
503
+ sum += textureSampleCompareLevel(map, shadow_sampler, uv + vec2f(f32(x), f32(y)) * texel, layer, depth);
504
+ }
505
+ }
506
+ return sum / 9.0;
507
+ }
508
+ let angle = ign(frag) * 6.2831853;
509
+ let c = cos(angle);
510
+ let s = sin(angle);
511
+ let radius = (1.5 + softness) * texel;
512
+ for (var i = 0; i < 16; i++) {
513
+ let p = POISSON[i];
514
+ let o = vec2f(c * p.x - s * p.y, s * p.x + c * p.y) * radius;
515
+ sum += textureSampleCompareLevel(map, shadow_sampler, uv + o, layer, depth);
516
+ }
517
+ return sum / 16.0;
518
+ }
519
+
520
+ fn clip_to_uv(clip: vec4f) -> vec3f {
521
+ let ndc = clip.xyz / clip.w;
522
+ return vec3f(ndc.x * 0.5 + 0.5, 0.5 - ndc.y * 0.5, ndc.z);
523
+ }
524
+
525
+ fn in_unit(uv: vec2f) -> bool {
526
+ return all(uv >= vec2f(0.0)) && all(uv <= vec2f(1.0));
527
+ }
528
+
529
+ /** The cascade a view depth falls in, or -1 beyond the last. */
530
+ fn cascade_index(view_depth: f32) -> i32 {
531
+ let count = i32(shadows.cascade_params.x);
532
+ for (var i = 0; i < count; i++) {
533
+ if (view_depth < shadows.cascade_splits[i]) { return i; }
534
+ }
535
+ return -1;
536
+ }
537
+
538
+ fn directional_shadow(world: vec3f, n: vec3f, view_depth: f32, frag: vec2f) -> f32 {
539
+ let i = cascade_index(view_depth);
540
+ if (i < 0) { return 1.0; }
541
+ let l = shadows.cascade_light.xyz;
542
+ let biased = world + l * shadows.cascade_params.y + n * shadows.cascade_params.z * shadows.cascade_texel[i];
543
+ let p = clip_to_uv(shadows.cascade_view_proj[i] * vec4f(biased, 1.0));
544
+ if (!in_unit(p.xy)) { return 1.0; }
545
+ return pcf(cascade_maps, i, p.xy, p.z, shadows.sizes.x, shadows.cascade_params.w, frag);
546
+ }
547
+
548
+ fn spot_shadow(index: u32, world: vec3f, n: vec3f, light_pos: vec3f, bias: f32, normal_bias: f32, softness: f32, frag: vec2f) -> f32 {
549
+ let to_light = light_pos - world;
550
+ let d = length(to_light);
551
+ let texel = 2.0 * d * shadows.spot_params[index].x / shadows.sizes.y;
552
+ let biased = world + to_light / d * bias + n * normal_bias * texel;
553
+ let p = clip_to_uv(shadows.spot_view_proj[index] * vec4f(biased, 1.0));
554
+ if (!in_unit(p.xy) || p.z <= 0.0) { return 1.0; }
555
+ return pcf(spot_maps, i32(index), p.xy, p.z, shadows.sizes.y, softness, frag);
556
+ }
557
+
558
+ fn point_shadow(index: u32, world: vec3f, n: vec3f, bias: f32, normal_bias: f32, softness: f32, frag: vec2f) -> f32 {
559
+ let light_pos = shadows.point_position[index].xyz;
560
+ let to_light = light_pos - world;
561
+ let d = length(to_light);
562
+ let texel = 2.0 * d * shadows.sizes.z / shadows.sizes.y;
563
+ let biased = world + to_light / d * bias + n * normal_bias * texel;
564
+ // The face whose axis is the major axis of the direction from the light (+X, -X, +Y, -Y, +Z, -Z).
565
+ let v = biased - light_pos;
566
+ let a = abs(v);
567
+ var face = 0u;
568
+ if (a.x >= a.y && a.x >= a.z) { face = select(1u, 0u, v.x > 0.0); }
569
+ else if (a.y >= a.z) { face = select(3u, 2u, v.y > 0.0); }
570
+ else { face = select(5u, 4u, v.z > 0.0); }
571
+ let layer = index * 6u + face;
572
+ let p = clip_to_uv(shadows.point_view_proj[layer] * vec4f(biased, 1.0));
573
+ return pcf(point_maps, i32(layer), p.xy, p.z, shadows.sizes.y, softness, frag);
574
+ }`,
575
+ "shard::pbr::lighting": `
576
+ import shard::view::view;
577
+ import shard::pbr::types::PbrInput;
578
+ import shard::pbr::brdf::{ PI, d_ggx, v_smith_ggx_correlated, f_schlick };
579
+ import shard::pbr::lights::{ lights, clusters, directional, LIGHT_SPOT, NO_SHADOW, CLUSTER_X, CLUSTER_Y, CLUSTER_Z, CLUSTER_COUNT, MAX_PER_CLUSTER };
580
+ import shard::pbr::shadows::{ directional_shadow, spot_shadow, point_shadow, cascade_index };
581
+ import shard::pbr::environment::environment_light;
582
+
583
+ const FLAG_RECEIVER: u32 = 4u;
584
+
585
+ /** Screen-space ambient occlusion (Ssao), full resolution; 1\xD71 white without it. */
586
+ @group(0) @binding(15) var ao_texture: texture_2d<f32>;
587
+
588
+ /** The cluster of a fragment, or -1 outside the clustered depth range. */
589
+ fn cluster_of(frag_coord: vec4f, view_depth: f32) -> i32 {
590
+ let near = view.clusterParams.x;
591
+ let far = view.clusterParams.y;
592
+ if (view_depth < near || view_depth > far) { return -1; }
593
+ let tx = min(u32(frag_coord.x * view.viewport.z * f32(CLUSTER_X)), CLUSTER_X - 1u);
594
+ let ty = min(u32(frag_coord.y * view.viewport.w * f32(CLUSTER_Y)), CLUSTER_Y - 1u);
595
+ let tz = min(u32(max(log(view_depth / near) * view.clusterParams.z, 0.0)), CLUSTER_Z - 1u);
596
+ return i32(tx + ty * CLUSTER_X + tz * CLUSTER_X * CLUSTER_Y);
597
+ }
598
+
599
+ /** GGX specular (scaled by spec_scale) plus Lambert diffuse for one light direction. */
600
+ fn brdf(n: vec3f, v: vec3f, l: vec3f, a: f32, diffuse_color: vec3f, f0: vec3f, spec_scale: f32) -> vec3f {
601
+ let h = normalize(v + l);
602
+ let n_dot_l = max(dot(n, l), 0.0);
603
+ let n_dot_v = max(dot(n, v), 1e-4);
604
+ let n_dot_h = max(dot(n, h), 0.0);
605
+ let v_dot_h = max(dot(v, h), 0.0);
606
+ let specular = d_ggx(n_dot_h, a) * v_smith_ggx_correlated(n_dot_v, n_dot_l, a) * f_schlick(f0, v_dot_h);
607
+ return (diffuse_color / PI + specular * spec_scale) * n_dot_l;
608
+ }
609
+
610
+ /** Heat map for the cluster debug view: blue (1 light) through red (32+). */
611
+ fn heat(count: u32) -> vec3f {
612
+ if (count == 0u) { return vec3f(0.02); }
613
+ let t = clamp(f32(count) / 32.0, 0.0, 1.0);
614
+ return mix(mix(vec3f(0.0, 0.2, 1.0), vec3f(0.0, 1.0, 0.2), clamp(t * 2.0, 0.0, 1.0)), vec3f(1.0, 0.1, 0.0), clamp(t * 2.0 - 1.0, 0.0, 1.0));
615
+ }
616
+
617
+ /**
618
+ * Lighting stage: the only place lights are evaluated. Returns scene-referred radiance.
619
+ * Directional lights (with cascaded shadows), then clustered point and spot lights, then ambient.
620
+ */
621
+ fn apply_lighting(p: PbrInput, world_position: vec3f, frag_coord: vec4f, flags: u32) -> vec3f {
622
+ let n = p.normal;
623
+ let v = normalize(view.cameraPosition - world_position);
624
+ let a = p.roughness * p.roughness;
625
+ let diffuse_color = p.base_color * (1.0 - p.metallic);
626
+ let f0 = mix(vec3f(0.04), p.base_color, p.metallic);
627
+ let receives = (flags & FLAG_RECEIVER) != 0u;
628
+ let view_depth = -(view.view * vec4f(world_position, 1.0)).z;
629
+ var color = vec3f(0.0);
630
+
631
+ // Directional lights: illuminance (lux) times the BRDF.
632
+ for (var i = 0u; i < directional.count; i++) {
633
+ let light = directional.lights[i];
634
+ let l = normalize(light.direction);
635
+ var shadow = 1.0;
636
+ if (receives && light.shadowed != 0u) {
637
+ shadow = directional_shadow(world_position, n, view_depth, frag_coord.xy);
638
+ }
639
+ color += brdf(n, v, l, a, diffuse_color, f0, 1.0) * light.color * view.sunTransmittance[i].rgb * shadow;
640
+ }
641
+
642
+ // Point and spot lights from this fragment's cluster: intensity (cd) / d\xB2 \xD7 window.
643
+ let cluster = cluster_of(frag_coord, view_depth);
644
+ var count = 0u;
645
+ if (cluster >= 0) {
646
+ count = clusters[cluster];
647
+ let base = CLUSTER_COUNT + u32(cluster) * MAX_PER_CLUSTER;
648
+ for (var k = 0u; k < count; k++) {
649
+ let light = lights[clusters[base + k]];
650
+ let to_light = light.position - world_position;
651
+ let d2 = max(dot(to_light, to_light), 1e-4);
652
+ let d = sqrt(d2);
653
+ let l = to_light / d;
654
+ let ratio = d / light.range;
655
+ let r4 = ratio * ratio * ratio * ratio;
656
+ let window = clamp(1.0 - r4, 0.0, 1.0);
657
+ var attenuation = window * window / d2;
658
+ if (light.kind == LIGHT_SPOT) {
659
+ let cd = dot(-l, light.direction);
660
+ let spot = clamp(cd * light.spot_scale + light.spot_offset, 0.0, 1.0);
661
+ attenuation *= spot * spot;
662
+ }
663
+ if (attenuation <= 0.0) { continue; }
664
+ if (receives && light.shadow != NO_SHADOW) {
665
+ if (light.kind == LIGHT_SPOT) {
666
+ attenuation *= spot_shadow(light.shadow, world_position, n, light.position, light.shadow_bias, light.shadow_normal_bias, light.shadow_softness, frag_coord.xy);
667
+ } else {
668
+ attenuation *= point_shadow(light.shadow, world_position, n, light.shadow_bias, light.shadow_normal_bias, light.shadow_softness, frag_coord.xy);
669
+ }
670
+ }
671
+ // A spherical emitter widens the highlight (Karis' roughness modification, energy-normalized).
672
+ let a_light = clamp(a + light.radius / (2.0 * d), 0.0, 1.0);
673
+ let norm = (a / a_light) * (a / a_light);
674
+ color += brdf(n, v, l, a_light, diffuse_color, f0, norm) * light.color * attenuation;
675
+ }
676
+ }
677
+
678
+ // Ambient: image-based lighting from the environment, or the uniform AmbientLight (cd/m\xB2).
679
+ // SSAO darkens it on opaque surfaces (it measures the depth buffer, which blended ones aren't in).
680
+ var surface = p;
681
+ if (p.alpha >= 0.999) {
682
+ let dims = vec2i(textureDimensions(ao_texture));
683
+ surface.occlusion *= textureLoad(ao_texture, min(vec2i(frag_coord.xy), dims - 1), 0).r;
684
+ }
685
+ if (view.envParams.w > 0.5) {
686
+ color += environment_light(surface, n, v);
687
+ } else {
688
+ color += diffuse_color * view.ambient * surface.occlusion;
689
+ }
690
+
691
+ let debug = u32(view.clusterParams.w);
692
+ if (debug == 1u) {
693
+ color = heat(count) / max(view.exposure, 1e-12) * 0.5;
694
+ } else if (debug == 3u) {
695
+ // LOD levels: 0 green, 1 yellow, 2 orange, 3+ red.
696
+ let tints = array<vec3f, 4>(vec3f(0.2, 1.0, 0.2), vec3f(1.0, 1.0, 0.2), vec3f(1.0, 0.5, 0.1), vec3f(1.0, 0.1, 0.1));
697
+ color = (color + vec3f(0.05) / max(view.exposure, 1e-12)) * tints[min(flags >> 24u, 3u)];
698
+ } else if (debug == 2u) {
699
+ let tints = array<vec3f, 5>(vec3f(1.0, 0.3, 0.3), vec3f(0.3, 1.0, 0.3), vec3f(0.3, 0.5, 1.0), vec3f(1.0, 1.0, 0.3), vec3f(0.6));
700
+ let i = cascade_index(view_depth);
701
+ color *= tints[select(4, i, i >= 0)];
702
+ }
703
+ return color;
704
+ }`,
705
+ "shard::lighting::cluster": `
706
+ import shard::view::view;
707
+
708
+ struct ViewLight {
709
+ /** View-space position (xyz), range (w). */
710
+ position: vec4f,
711
+ /** View-space spot axis (xyz), cos(outer angle), or < -1 for point lights (w). */
712
+ axis: vec4f,
713
+ index: u32,
714
+ _pad0: u32,
715
+ _pad1: u32,
716
+ _pad2: u32,
717
+ }
718
+
719
+ struct ViewLights {
720
+ count: u32,
721
+ _pad0: u32,
722
+ _pad1: u32,
723
+ _pad2: u32,
724
+ lights: array<ViewLight>,
725
+ }
726
+
727
+ struct ClusterAabb { min: vec4f, max: vec4f }
728
+
729
+ const CLUSTER_COUNT: u32 = 3456u;
730
+ const MAX_PER_CLUSTER: u32 = 128u;
731
+
732
+ @group(0) @binding(1) var<storage, read> view_lights: ViewLights;
733
+ @group(0) @binding(2) var<storage, read> aabbs: array<ClusterAabb>;
734
+ @group(0) @binding(3) var<storage, read_write> clusters: array<u32>;
735
+ @group(0) @binding(4) var<storage, read_write> stats: array<atomic<u32>, 4>;
736
+
737
+ /** Sphere (or cone, for spots) against a cluster's view-space box. Mirrors lightTouchesCluster. */
738
+ fn touches(bmin: vec3f, bmax: vec3f, light: ViewLight) -> bool {
739
+ let p = light.position.xyz;
740
+ let range = light.position.w;
741
+ let e = clamp(p, bmin, bmax) - p;
742
+ if (dot(e, e) > range * range) { return false; }
743
+ let cos_outer = light.axis.w;
744
+ if (cos_outer < -1.0) { return true; }
745
+ let center = (bmin + bmax) * 0.5 - p;
746
+ let half = (bmax - bmin) * 0.5;
747
+ let radius = sqrt(dot(half, half));
748
+ let len_sq = dot(center, center);
749
+ let v1 = dot(center, light.axis.xyz);
750
+ let sin_outer = sqrt(max(0.0, 1.0 - cos_outer * cos_outer));
751
+ let closest = cos_outer * sqrt(max(0.0, len_sq - v1 * v1)) - v1 * sin_outer;
752
+ if (closest > radius) { return false; }
753
+ if (v1 > radius + range) { return false; }
754
+ if (v1 < -radius) { return false; }
755
+ return true;
756
+ }
757
+
758
+ @compute @workgroup_size(64)
759
+ fn main(@builtin(global_invocation_id) id: vec3u) {
760
+ let c = id.x;
761
+ if (c >= CLUSTER_COUNT) { return; }
762
+ let bmin = aabbs[c].min.xyz;
763
+ let bmax = aabbs[c].max.xyz;
764
+ var n = 0u;
765
+ let base = CLUSTER_COUNT + c * MAX_PER_CLUSTER;
766
+ for (var i = 0u; i < view_lights.count; i++) {
767
+ let light = view_lights.lights[i];
768
+ if (!touches(bmin, bmax, light)) { continue; }
769
+ if (n < MAX_PER_CLUSTER) { clusters[base + n] = light.index; }
770
+ n++;
771
+ }
772
+ clusters[c] = min(n, MAX_PER_CLUSTER);
773
+ atomicMax(&stats[0], n);
774
+ if (n > MAX_PER_CLUSTER) { atomicAdd(&stats[1], 1u); }
775
+ }`,
776
+ "shard::pbr::shadow": `
777
+ import shard::pbr::types::VertexOutput;
778
+ import shard::mesh::mesh_vertex_at;
779
+ import shard::pbr::material::pbr_input;
780
+ import shard::pbr::standard::material;
781
+
782
+ struct ShadowView { view_proj: mat4x4f }
783
+ @group(0) @binding(0) var<uniform> shadow_view: ShadowView;
784
+
785
+ @vertex fn vs(
786
+ @builtin(instance_index) instance_index: u32,
787
+ @builtin(vertex_index) vertex_index: u32,
788
+ @location(0) position: vec3f,
789
+ @location(1) normal: vec3f,
790
+ @location(2) uv: vec2f,
791
+ @location(3) uv1: vec2f,
792
+ @location(4) tangent: vec4f,
793
+ ) -> VertexOutput {
794
+ var out = mesh_vertex_at(instance_index, vertex_index, position, normal, uv, uv1, tangent);
795
+ out.clip = shadow_view.view_proj * vec4f(out.world_position, 1.0);
796
+ return out;
797
+ }
798
+
799
+ /** Masked materials cut holes in their shadows too. */
800
+ @fragment fn fs_mask(in: VertexOutput) {
801
+ let p = pbr_input(in);
802
+ if (p.alpha < material.alphaCutoff) { discard; }
803
+ }`,
804
+ "shard::color": `
805
+ fn linear_to_srgb(c: vec3f) -> vec3f {
806
+ let lo = c * 12.92;
807
+ let hi = 1.055 * pow(c, vec3f(1.0 / 2.4)) - 0.055;
808
+ return select(hi, lo, c <= vec3f(0.0031308));
809
+ }
810
+
811
+ fn srgb_to_linear(c: vec3f) -> vec3f {
812
+ let lo = c / 12.92;
813
+ let hi = pow((c + 0.055) / 1.055, vec3f(2.4));
814
+ return select(hi, lo, c <= vec3f(0.04045));
815
+ }
816
+
817
+ fn luminance(c: vec3f) -> f32 {
818
+ return dot(c, vec3f(0.2126, 0.7152, 0.0722));
819
+ }
820
+
821
+ /** Interleaved gradient noise (Jimenez 2014): cheap, stable per pixel. */
822
+ fn ign(p: vec2f) -> f32 {
823
+ return fract(52.9829189 * fract(dot(p, vec2f(0.06711056, 0.00583715))));
824
+ }`,
825
+ "shard::tonemap": `
826
+ /** ACES filmic, Narkowicz's fit. Punchy contrast; saturated highlights shift toward white. */
827
+ fn tonemap_aces(x: vec3f) -> vec3f {
828
+ return clamp((x * (2.51 * x + 0.03)) / (x * (2.43 * x + 0.59) + 0.14), vec3f(0.0), vec3f(1.0));
829
+ }
830
+
831
+ fn agx_contrast(x: vec3f) -> vec3f {
832
+ // Sigmoid fit of AgX's default base contrast (Wrensch's polynomial).
833
+ let x2 = x * x;
834
+ let x4 = x2 * x2;
835
+ return 15.5 * x4 * x2 - 40.14 * x4 * x + 31.96 * x4 - 6.868 * x2 * x + 0.4298 * x2 + 0.1191 * x - 0.00232;
836
+ }
837
+
838
+ /**
839
+ * AgX (Sobotka): inset toward the achromatic axis, log2 encoding, a sigmoid, then outset. Bright
840
+ * saturated colors desaturate gracefully instead of skewing hue. Output is display-linear.
841
+ */
842
+ fn tonemap_agx(c: vec3f) -> vec3f {
843
+ let inset = mat3x3f(
844
+ 0.842479062253094, 0.0423282422610123, 0.0423756549057051,
845
+ 0.0784335999999992, 0.878468636469772, 0.0784336,
846
+ 0.0792237451477643, 0.0791661274605434, 0.879142973793104,
847
+ );
848
+ let outset = mat3x3f(
849
+ 1.19687900512017, -0.0528968517574562, -0.0529716355144438,
850
+ -0.0980208811401368, 1.15190312990417, -0.0980434501171241,
851
+ -0.0990297440797205, -0.0989611768448433, 1.15107367264116,
852
+ );
853
+ let min_ev = -12.47393;
854
+ let max_ev = 4.026069;
855
+ var x = inset * c;
856
+ x = clamp(log2(max(x, vec3f(1e-10))), vec3f(min_ev), vec3f(max_ev));
857
+ x = (x - min_ev) / (max_ev - min_ev);
858
+ x = agx_contrast(x);
859
+ x = outset * x;
860
+ // The sigmoid produces gamma-2.2 display values; linearize for the common sRGB encode.
861
+ return clamp(pow(max(x, vec3f(0.0)), vec3f(2.2)), vec3f(0.0), vec3f(1.0));
862
+ }
863
+
864
+ /** Khronos PBR Neutral: base colors come out as authored under neutral light; highlights roll off. */
865
+ fn tonemap_pbr_neutral(color: vec3f) -> vec3f {
866
+ let start = 0.8 - 0.04;
867
+ let desaturation = 0.15;
868
+ let x = min(color.r, min(color.g, color.b));
869
+ let offset = select(0.04, x - 6.25 * x * x, x < 0.08);
870
+ var c = color - offset;
871
+ let peak = max(c.r, max(c.g, c.b));
872
+ if (peak < start) { return clamp(c, vec3f(0.0), vec3f(1.0)); }
873
+ let d = 1.0 - start;
874
+ let new_peak = 1.0 - d * d / (peak + d - start);
875
+ c = c * (new_peak / peak);
876
+ let g = 1.0 - 1.0 / (desaturation * (peak - new_peak) + 1.0);
877
+ return clamp(mix(c, vec3f(new_peak), g), vec3f(0.0), vec3f(1.0));
878
+ }
879
+
880
+ fn tonemap_reinhard(x: vec3f) -> vec3f {
881
+ return x / (1.0 + x);
882
+ }
883
+
884
+ /** Applies a curve by index: 0 aces, 1 agx, 2 pbr-neutral, 3 reinhard, 4 none. */
885
+ fn tonemap(x: vec3f, curve: u32) -> vec3f {
886
+ switch curve {
887
+ case 0u: { return tonemap_aces(x); }
888
+ case 1u: { return tonemap_agx(x); }
889
+ case 2u: { return tonemap_pbr_neutral(x); }
890
+ case 3u: { return tonemap_reinhard(x); }
891
+ default: { return clamp(x, vec3f(0.0), vec3f(1.0)); }
892
+ }
893
+ }`,
894
+ "shard::fullscreen": `
895
+ struct FullscreenOutput {
896
+ @builtin(position) clip: vec4f,
897
+ @location(0) uv: vec2f,
898
+ }
899
+
900
+ /** One triangle that covers the screen; uv (0,0) is the top-left. */
901
+ @vertex fn vs(@builtin(vertex_index) i: u32) -> FullscreenOutput {
902
+ let xy = vec2f(f32((i << 1u) & 2u), f32(i & 2u));
903
+ var out: FullscreenOutput;
904
+ out.clip = vec4f(xy * 2.0 - 1.0, 0.0, 1.0);
905
+ out.uv = vec2f(xy.x, 1.0 - xy.y);
906
+ return out;
907
+ }`,
908
+ "shard::pbr::forward": `
909
+ import shard::view::view;
910
+ import shard::pbr::types::VertexOutput;
911
+ import shard::mesh::mesh_vertex_at;
912
+ import shard::pbr::material::{ pbr_input, fragment_output };
913
+ import shard::pbr::standard::material;
914
+ import shard::pbr::lighting::apply_lighting;
915
+
916
+ @vertex fn vs(
917
+ @builtin(instance_index) instance_index: u32,
918
+ @builtin(vertex_index) vertex_index: u32,
919
+ @location(0) position: vec3f,
920
+ @location(1) normal: vec3f,
921
+ @location(2) uv: vec2f,
922
+ @location(3) uv1: vec2f,
923
+ @location(4) tangent: vec4f,
924
+ ) -> VertexOutput {
925
+ var out = mesh_vertex_at(instance_index, vertex_index, position, normal, uv, uv1, tangent);
926
+ out.clip = view.viewProj * vec4f(out.world_position, 1.0);
927
+ return out;
928
+ }
929
+
930
+ @fragment fn fs(in: VertexOutput) -> @location(0) vec4f {
931
+ let p = pbr_input(in);
932
+ if (material.alphaMode == 1u && p.alpha < material.alphaCutoff) { discard; }
933
+ let color = (apply_lighting(p, in.world_position, in.clip, in.flags) + p.emissive) * view.exposure;
934
+ // Premultiplied blending expects color \xD7 alpha; additive and alpha blend multiply in hardware.
935
+ @if(PREMULTIPLY) return fragment_output(vec4f(color * p.alpha, p.alpha));
936
+ @if(!PREMULTIPLY) return fragment_output(vec4f(color, p.alpha));
937
+ }`,
938
+ "shard::post::depth_resolve": `
939
+ @group(0) @binding(0) var depth_ms: texture_depth_multisampled_2d;
940
+
941
+ @fragment fn fs(@builtin(position) p: vec4f) -> @builtin(frag_depth) f32 {
942
+ return textureLoad(depth_ms, vec2i(p.xy), 0);
943
+ }`,
944
+ // Shared with features: sampling the environment, the display stage, gbuffer packing.
945
+ "shard::pbr::environment": `
946
+ import shard::view::view;
947
+ import shard::pbr::types::PbrInput;
948
+
949
+ @group(0) @binding(9) var env_specular: texture_cube<f32>;
950
+ @group(0) @binding(10) var env_lut: texture_2d<f32>;
951
+ @group(0) @binding(11) var env_sampler: sampler;
952
+ /** SH9, convolved with the cosine lobe and divided by \u03C0: dot with the basis = irradiance / \u03C0. */
953
+ @group(0) @binding(12) var<storage, read> env_sh: array<vec4f, 9>;
954
+ @group(0) @binding(13) var env_source: texture_cube<f32>;
955
+
956
+ const ENV_SPECULAR_MIPS: f32 = 5.0;
957
+
958
+ /** A direction in the environment's frame: rotated by -rotation about +Y. */
959
+ fn env_rotate(d: vec3f) -> vec3f {
960
+ let c = view.envParams.y;
961
+ let s = view.envParams.z;
962
+ return vec3f(c * d.x - s * d.z, d.y, s * d.x + c * d.z);
963
+ }
964
+
965
+ /** Irradiance / \u03C0 from SH9 (a luminance, in the environment's units). */
966
+ fn env_irradiance(n: vec3f) -> vec3f {
967
+ let d = env_rotate(n);
968
+ var e = env_sh[0].rgb * 0.282095;
969
+ e += env_sh[1].rgb * 0.488603 * d.y;
970
+ e += env_sh[2].rgb * 0.488603 * d.z;
971
+ e += env_sh[3].rgb * 0.488603 * d.x;
972
+ e += env_sh[4].rgb * 1.092548 * d.x * d.y;
973
+ e += env_sh[5].rgb * 1.092548 * d.y * d.z;
974
+ e += env_sh[6].rgb * 0.315392 * (3.0 * d.z * d.z - 1.0);
975
+ e += env_sh[7].rgb * 1.092548 * d.x * d.z;
976
+ e += env_sh[8].rgb * 0.546274 * (d.x * d.x - d.y * d.y);
977
+ return max(e, vec3f(0.0));
978
+ }
979
+
980
+ /**
981
+ * Split-sum image-based lighting: irradiance(n) \xB7 diffuse \xB7 (1 \u2212 F) \xB7 occlusion
982
+ * + prefiltered(r, roughness) \xB7 (F\u2080\xB7A + B), in cd/m\xB2.
983
+ */
984
+ fn environment_light(p: PbrInput, n: vec3f, v: vec3f) -> vec3f {
985
+ let n_dot_v = clamp(dot(n, v), 1e-4, 1.0);
986
+ let f0 = mix(vec3f(0.04), p.base_color, p.metallic);
987
+ let diffuse_color = p.base_color * (1.0 - p.metallic);
988
+ // Fresnel with roughness (Lagarde): rough surfaces don't reach full grazing reflectance.
989
+ let f = f0 + (max(vec3f(1.0 - p.roughness), f0) - f0) * pow(1.0 - n_dot_v, 5.0);
990
+ let r = reflect(-v, n);
991
+ let prefiltered = textureSampleLevel(env_specular, env_sampler, env_rotate(r), p.roughness * ENV_SPECULAR_MIPS).rgb;
992
+ let ab = textureSampleLevel(env_lut, env_sampler, vec2f(n_dot_v, p.roughness), 0.0).rg;
993
+ // Specular occlusion from ambient occlusion (Lagarde & de Rousiers).
994
+ let ao = p.occlusion;
995
+ let spec_ao = clamp(pow(n_dot_v + ao, exp2(-16.0 * p.roughness - 1.0)) - 1.0 + ao, 0.0, 1.0);
996
+ let diffuse = env_irradiance(n) * diffuse_color * (vec3f(1.0) - f) * ao;
997
+ let specular = prefiltered * (f0 * ab.x + ab.y) * spec_ao;
998
+ return (diffuse + specular) * view.envParams.x;
999
+ }
1000
+
1001
+ /** The environment's own radiance toward d (the background), in cd/m\xB2. */
1002
+ fn environment_background(d: vec3f) -> vec3f {
1003
+ return textureSampleLevel(env_source, env_sampler, env_rotate(d), 0.0).rgb * view.envParams.x;
1004
+ }`,
1005
+ "shard::post::tonemap": `
1006
+ import shard::color::{ linear_to_srgb, srgb_to_linear, ign, luminance };
1007
+ import shard::tonemap::tonemap;
1008
+
1009
+ struct TonemapParams {
1010
+ /** curve, dither, flags (1 grading, 2 vignette, 4 LUT, 8 sRGB LUT), 0. */
1011
+ mode: vec4u,
1012
+ /** White balance as LMS scale (xyz), 0. */
1013
+ balance: vec4f,
1014
+ /** saturation, contrast, 0, 0. */
1015
+ grade: vec4f,
1016
+ lift: vec4f,
1017
+ gamma: vec4f,
1018
+ gain: vec4f,
1019
+ /** Vignette intensity, smoothness, aspect, 0. */
1020
+ vignette: vec4f,
1021
+ /** 1 / width, 1 / height of the view. */
1022
+ texel: vec4f,
1023
+ }
1024
+
1025
+ @group(0) @binding(0) var hdr: texture_2d<f32>;
1026
+ @group(0) @binding(1) var<uniform> params: TonemapParams;
1027
+ @group(0) @binding(2) var lut: texture_2d<f32>;
1028
+ @group(0) @binding(3) var lut_sampler: sampler;
1029
+
1030
+ const LIN_TO_LMS = mat3x3f(
1031
+ vec3f(3.90405e-1, 7.08416e-2, 2.31082e-2),
1032
+ vec3f(5.49941e-1, 9.63172e-1, 1.28021e-1),
1033
+ vec3f(8.92632e-3, 1.35775e-3, 9.36245e-1),
1034
+ );
1035
+ const LMS_TO_LIN = mat3x3f(
1036
+ vec3f(2.85847, -2.10182e-1, -4.18120e-2),
1037
+ vec3f(-1.62879, 1.15820, -1.18169e-1),
1038
+ vec3f(-2.48910e-2, 3.24281e-4, 1.06867),
1039
+ );
1040
+
1041
+ /** White balance, contrast around mid gray, saturation, then lift/gamma/gain, in scene-linear HDR. */
1042
+ fn grade(c0: vec3f) -> vec3f {
1043
+ var c = LMS_TO_LIN * ((LIN_TO_LMS * c0) * params.balance.xyz);
1044
+ c = 0.18 * pow(max(c, vec3f(0.0)) / 0.18, vec3f(params.grade.y));
1045
+ let l = luminance(c);
1046
+ c = max(mix(vec3f(l), c, params.grade.x), vec3f(0.0));
1047
+ c = pow(max(c * params.gain.xyz + params.lift.xyz, vec3f(0.0)), 1.0 / params.gamma.xyz);
1048
+ return c;
1049
+ }
1050
+
1051
+ /** A 32\xB3 LUT stored as a 1024\xD732 strip: slices of red (x) by green (y), one per blue step. */
1052
+ fn apply_lut(c: vec3f) -> vec3f {
1053
+ let x = clamp(c, vec3f(0.0), vec3f(1.0)) * 31.0;
1054
+ let slice = floor(x.b);
1055
+ let f = x.b - slice;
1056
+ let uv0 = vec2f((slice * 32.0 + x.r + 0.5) / 1024.0, (x.g + 0.5) / 32.0);
1057
+ let uv1 = uv0 + vec2f(select(32.0, 0.0, slice >= 31.0) / 1024.0, 0.0);
1058
+ var a = textureSampleLevel(lut, lut_sampler, uv0, 0.0).rgb;
1059
+ var b = textureSampleLevel(lut, lut_sampler, uv1, 0.0).rgb;
1060
+ // An sRGB-format texture decoded the stored values: encode them back.
1061
+ if ((params.mode.z & 8u) != 0u) { a = linear_to_srgb(a); b = linear_to_srgb(b); }
1062
+ return mix(a, b, f);
1063
+ }
1064
+
1065
+ @fragment fn fs(@builtin(position) p: vec4f) -> @location(0) vec4f {
1066
+ let texel = textureLoad(hdr, vec2i(p.xy), 0);
1067
+ var hdr_color = max(texel.rgb, vec3f(0.0));
1068
+ let flags = params.mode.z;
1069
+ if ((flags & 1u) != 0u) { hdr_color = grade(hdr_color); }
1070
+ if ((flags & 2u) != 0u) {
1071
+ let uv = p.xy * params.texel.xy - 0.5;
1072
+ let d = length(uv * vec2f(params.vignette.z, 1.0)) / length(vec2f(params.vignette.z, 1.0) * 0.5);
1073
+ let fall = smoothstep(1.0 - params.vignette.y, 1.0 + params.vignette.y * 0.5, d);
1074
+ hdr_color *= 1.0 - params.vignette.x * fall;
1075
+ }
1076
+ var c = linear_to_srgb(tonemap(hdr_color, params.mode.x));
1077
+ if ((flags & 4u) != 0u) { c = apply_lut(c); }
1078
+ if (params.mode.y != 0u) {
1079
+ // Triangular noise of \xB11 LSB, in display space: breaks up banding in gradients.
1080
+ let n = ign(p.xy) + ign(p.xy + vec2f(71.0, 13.0)) - 1.0;
1081
+ c = c + vec3f(n / 255.0);
1082
+ }
1083
+ // sRGB targets encode in hardware: hand them linear values.
1084
+ @if(SRGB_TARGET) c = srgb_to_linear(clamp(c, vec3f(0.0), vec3f(1.0)));
1085
+ return vec4f(c, 1.0);
1086
+ }`,
1087
+ "shard::post::upscale": `
1088
+ struct Upscale {
1089
+ /** 1 / output size (xy), sharpening (z), 0. */
1090
+ params: vec4f,
1091
+ }
1092
+
1093
+ @group(0) @binding(0) var input: texture_2d<f32>;
1094
+ @group(0) @binding(1) var input_sampler: sampler;
1095
+ @group(0) @binding(2) var<uniform> upscale: Upscale;
1096
+
1097
+ fn at(uv: vec2f) -> vec3f {
1098
+ return textureSampleLevel(input, input_sampler, uv, 0.0).rgb;
1099
+ }
1100
+
1101
+ /**
1102
+ * The render-resolution image onto the display (0051): bilinear, then an unsharp mask over the
1103
+ * four neighbors one source texel away, clamped to their range so edges can't ring.
1104
+ */
1105
+ @fragment fn fs(@builtin(position) frag: vec4f) -> @location(0) vec4f {
1106
+ let uv = frag.xy * upscale.params.xy;
1107
+ let c = at(uv);
1108
+ let k = upscale.params.z;
1109
+ if (k <= 0.0) { return vec4f(c, 1.0); }
1110
+ let texel = 1.0 / vec2f(textureDimensions(input));
1111
+ let n = at(uv + vec2f(0.0, -texel.y));
1112
+ let s = at(uv + vec2f(0.0, texel.y));
1113
+ let e = at(uv + vec2f(texel.x, 0.0));
1114
+ let w = at(uv + vec2f(-texel.x, 0.0));
1115
+ let lo = min(c, min(min(n, s), min(e, w)));
1116
+ let hi = max(c, max(max(n, s), max(e, w)));
1117
+ let sharp = c + (4.0 * c - (n + s + e + w)) * (0.25 * k);
1118
+ return vec4f(clamp(sharp, lo, hi), 1.0);
1119
+ }`,
1120
+ "shard::post::common": `
1121
+ import shard::view::view;
1122
+
1123
+ /** A pixel's uv from its fragment coordinate. */
1124
+ fn uv_of(frag: vec2f) -> vec2f {
1125
+ return frag * view.viewport.zw;
1126
+ }
1127
+
1128
+ /** World position of a uv at a (reversed-Z) depth. Depth 0 is infinitely far: pass a floor. */
1129
+ fn world_at(uv: vec2f, depth: f32) -> vec3f {
1130
+ let ndc = vec2f(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0);
1131
+ let w = view.invViewProj * vec4f(ndc, depth, 1.0);
1132
+ return w.xyz / w.w;
1133
+ }
1134
+
1135
+ /** Distance along the view axis, in meters. */
1136
+ fn view_depth(world: vec3f) -> f32 {
1137
+ return -(view.view * vec4f(world, 1.0)).z;
1138
+ }
1139
+
1140
+ /**
1141
+ * Screen motion of a pixel in uv units: the prepass's value for geometry, the camera's own
1142
+ * rotation for the background (depth 0), which the prepass leaves empty.
1143
+ */
1144
+ fn motion(uv: vec2f, depth: f32, stored: vec2f) -> vec2f {
1145
+ if (depth > 0.0) { return stored; }
1146
+ let ndc = vec2f(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0);
1147
+ let dir = (view.invViewProj * vec4f(ndc, 0.0, 1.0)).xyz;
1148
+ let now = view.viewProjNoJitter * vec4f(dir, 0.0);
1149
+ let before = view.prevViewProj * vec4f(dir, 0.0);
1150
+ return (now.xy / now.w - before.xy / before.w) * vec2f(0.5, -0.5);
1151
+ }`
1152
+ };
1153
+ function registerShaders(library, modules) {
1154
+ for (const [path, source] of Object.entries(modules))
1155
+ library.register(path, source, `engine:${path}`);
1156
+ }
1157
+ function registerEngineShaders(library) {
1158
+ registerShaders(library, ENGINE_SHADERS);
1159
+ registerShaders(library, CULLING_SHADERS);
1160
+ }
1161
+ export {
1162
+ ENGINE_SHADERS,
1163
+ ViewUniform,
1164
+ materialLayout,
1165
+ registerEngineShaders,
1166
+ registerShaders,
1167
+ viewLayout
1168
+ };